Add weekly layout to record calendar (#22819)
## Summary - Add a week layout to record calendar views and persist the selected layout. - Render `DATE` calendars as an all-day week and `DATE_TIME` calendars as an hourly week. - Add an optional end date field across calendar configuration, metadata, persistence, and complete-view upserts. - Use configured end values for ranged and multi-day events, with a one-hour fallback when a `DATE_TIME` end is absent or invalid. - Keep calendar cards consistent with the existing compact view, including checkbox selection and whole-card record opening. - Gate the weekly layout and end-date behavior behind the public Labs `IS_CALENDAR_WEEK_VIEW_ENABLED` workspace feature flag. ## Week interactions - Show overlapping timed events side by side and cap the visible records at two per day. - Display start and end times on timed cards, enforce a readable 30-minute minimum height, and keep today’s text contrast stronger. - Drag timed events between days and times with 30-minute snapping while preserving their duration, including zero-duration events. - Show a create button when hovering a 30-minute slot; keyboard users can focus a day, move the slot with the arrow keys, and reach the same contextual action. - Initialize new records with the selected slot time and a compatible writable end value one hour later. - Show the workspace time zone and current-time indicator in timed weeks; date-only weeks keep the all-day section without an hourly grid. ## Configuration and data loading - Only allow end fields that match the start field type, and prevent selecting the same field for both boundaries. - Load records whose ranges overlap the visible period so month and week layouts display the same relevant records. - Resolve and persist calendar end fields when updating existing views through `upsert_complete_view`. - Fall back to Month and ignore the configured end field while the flag is disabled, without overwriting either persisted setting, so re-enabling restores the previous configuration. - Expose the flag in Labs and keep it default-off for workspaces without a stored value; enable it in the development seeder. <img width="1285" height="808" alt="Screenshot 2026-07-15 at 15 50 17" src="https://github.com/user-attachments/assets/b7e3f7f1-ca77-492f-8cce-cca186ebca0b" />
This commit is contained in:
+109
@@ -0,0 +1,109 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
|
||||
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
|
||||
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
|
||||
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
|
||||
import { useUpdateCurrentView } from '@/views/hooks/useUpdateCurrentView';
|
||||
import { useGetAvailableFieldsForCalendar } from '@/views/view-picker/hooks/useGetAvailableFieldsForCalendar';
|
||||
import { getAvailableCalendarEndFieldMetadataItems } from '@/views/view-picker/utils/getAvailableCalendarEndFieldMetadataItems';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconChevronLeft, useIcons } from 'twenty-ui/icon';
|
||||
import { MenuItemSelect } from 'twenty-ui/navigation';
|
||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
||||
|
||||
export const ObjectOptionsDropdownCalendarEndFieldsContent = () => {
|
||||
const { t } = useLingui();
|
||||
const { getIcon } = useIcons();
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
);
|
||||
|
||||
const { resetContent, closeDropdown } = useObjectOptionsDropdown();
|
||||
|
||||
const { currentView } = useGetCurrentViewOnly();
|
||||
const { updateCurrentView } = useUpdateCurrentView();
|
||||
const { availableFieldsForCalendar } = useGetAvailableFieldsForCalendar();
|
||||
|
||||
const setRecordIndexCalendarEndFieldMetadataId = useSetAtomState(
|
||||
recordIndexCalendarEndFieldMetadataIdState,
|
||||
);
|
||||
|
||||
const availableCalendarEndFieldMetadataItems =
|
||||
getAvailableCalendarEndFieldMetadataItems({
|
||||
availableFieldsForCalendar,
|
||||
calendarFieldMetadataId: currentView?.calendarFieldMetadataId,
|
||||
});
|
||||
|
||||
const filteredCalendarEndFields =
|
||||
availableCalendarEndFieldMetadataItems.filter((field) =>
|
||||
field.label.toLowerCase().includes(searchInput.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleCalendarEndFieldChange = async (
|
||||
fieldMetadataItem: FieldMetadataItem | null,
|
||||
) => {
|
||||
if (!isCalendarWeekViewEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const calendarEndFieldMetadataId = fieldMetadataItem?.id ?? null;
|
||||
|
||||
setRecordIndexCalendarEndFieldMetadataId(calendarEndFieldMetadataId);
|
||||
await updateCurrentView({ calendarEndFieldMetadataId });
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
if (!isCalendarWeekViewEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownContent>
|
||||
<DropdownMenuHeader
|
||||
StartComponent={
|
||||
<DropdownMenuHeaderLeftComponent
|
||||
onClick={() => resetContent()}
|
||||
Icon={IconChevronLeft}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t`End date field`}
|
||||
</DropdownMenuHeader>
|
||||
<DropdownMenuSearchInput
|
||||
autoFocus
|
||||
value={searchInput}
|
||||
placeholder={t`Search fields`}
|
||||
onChange={(event) => setSearchInput(event.target.value)}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItemsContainer>
|
||||
<MenuItemSelect
|
||||
selected={!isDefined(currentView?.calendarEndFieldMetadataId)}
|
||||
onClick={() => handleCalendarEndFieldChange(null)}
|
||||
text={t`None`}
|
||||
/>
|
||||
{filteredCalendarEndFields.map((fieldMetadataItem) => (
|
||||
<MenuItemSelect
|
||||
key={fieldMetadataItem.id}
|
||||
selected={
|
||||
fieldMetadataItem.id === currentView?.calendarEndFieldMetadataId
|
||||
}
|
||||
onClick={() => handleCalendarEndFieldChange(fieldMetadataItem)}
|
||||
LeftIcon={getIcon(fieldMetadataItem.icon)}
|
||||
text={fieldMetadataItem.label}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
);
|
||||
};
|
||||
+24
@@ -1,5 +1,6 @@
|
||||
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
|
||||
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
|
||||
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
|
||||
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
|
||||
@@ -13,6 +14,7 @@ import { useGetAvailableFieldsForCalendar } from '@/views/view-picker/hooks/useG
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconChevronLeft, IconSettings, useIcons } from 'twenty-ui/icon';
|
||||
import { MenuItem, MenuItemSelect } from 'twenty-ui/navigation';
|
||||
|
||||
@@ -32,6 +34,9 @@ export const ObjectOptionsDropdownCalendarFieldsContent = () => {
|
||||
const setRecordIndexCalendarFieldMetadataId = useSetAtomState(
|
||||
recordIndexCalendarFieldMetadataIdState,
|
||||
);
|
||||
const setRecordIndexCalendarEndFieldMetadataId = useSetAtomState(
|
||||
recordIndexCalendarEndFieldMetadataIdState,
|
||||
);
|
||||
|
||||
const calendarFieldMetadata = currentView?.calendarFieldMetadataId
|
||||
? objectMetadataItem.fields.find(
|
||||
@@ -39,6 +44,12 @@ export const ObjectOptionsDropdownCalendarFieldsContent = () => {
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const calendarEndFieldMetadata = currentView?.calendarEndFieldMetadataId
|
||||
? objectMetadataItem.fields.find(
|
||||
(field) => field.id === currentView.calendarEndFieldMetadataId,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const filteredCalendarFields = availableFieldsForCalendar.filter((field) =>
|
||||
field.label.toLowerCase().includes(searchInput.toLowerCase()),
|
||||
);
|
||||
@@ -46,9 +57,22 @@ export const ObjectOptionsDropdownCalendarFieldsContent = () => {
|
||||
const handleCalendarFieldChange = async (
|
||||
fieldMetadataItem: FieldMetadataItem,
|
||||
) => {
|
||||
const shouldClearCalendarEndField =
|
||||
isDefined(currentView?.calendarEndFieldMetadataId) &&
|
||||
(!isDefined(calendarEndFieldMetadata) ||
|
||||
calendarEndFieldMetadata.id === fieldMetadataItem.id ||
|
||||
calendarEndFieldMetadata.type !== fieldMetadataItem.type);
|
||||
|
||||
setRecordIndexCalendarFieldMetadataId(fieldMetadataItem.id);
|
||||
if (shouldClearCalendarEndField) {
|
||||
setRecordIndexCalendarEndFieldMetadataId(null);
|
||||
}
|
||||
|
||||
await updateCurrentView({
|
||||
calendarFieldMetadataId: fieldMetadataItem.id,
|
||||
...(shouldClearCalendarEndField
|
||||
? { calendarEndFieldMetadataId: null }
|
||||
: {}),
|
||||
});
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
+39
-6
@@ -1,5 +1,6 @@
|
||||
import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId';
|
||||
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
|
||||
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
|
||||
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
|
||||
@@ -12,6 +13,7 @@ import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/use
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useUpdateCurrentView } from '@/views/hooks/useUpdateCurrentView';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { Pill } from 'twenty-ui/data-display';
|
||||
import {
|
||||
@@ -21,13 +23,23 @@ import {
|
||||
IconTimelineEvent,
|
||||
} from 'twenty-ui/icon';
|
||||
import { MenuItemSelect } from 'twenty-ui/navigation';
|
||||
import { ViewCalendarLayout } from '~/generated-metadata/graphql';
|
||||
import {
|
||||
FeatureFlagKey,
|
||||
ViewCalendarLayout,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
export const ObjectOptionsDropdownCalendarViewContent = () => {
|
||||
const { resetContent } = useObjectOptionsDropdown();
|
||||
const recordIndexCalendarLayout = useAtomStateValue(
|
||||
recordIndexCalendarLayoutState,
|
||||
);
|
||||
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
);
|
||||
const supportedCalendarLayout = getSupportedRecordCalendarLayout({
|
||||
calendarLayout: recordIndexCalendarLayout,
|
||||
isCalendarWeekViewEnabled,
|
||||
});
|
||||
const setRecordIndexCalendarLayout = useSetAtomState(
|
||||
recordIndexCalendarLayoutState,
|
||||
);
|
||||
@@ -47,6 +59,18 @@ export const ObjectOptionsDropdownCalendarViewContent = () => {
|
||||
];
|
||||
|
||||
const handleCalendarViewChange = async (calendarView: ViewCalendarLayout) => {
|
||||
if (
|
||||
calendarView === ViewCalendarLayout.WEEK &&
|
||||
!isCalendarWeekViewEnabled
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (calendarView === supportedCalendarLayout) {
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
|
||||
setRecordIndexCalendarLayout(calendarView);
|
||||
await updateCurrentView({
|
||||
calendarLayout: calendarView,
|
||||
@@ -75,17 +99,26 @@ export const ObjectOptionsDropdownCalendarViewContent = () => {
|
||||
<SelectableListItem
|
||||
itemId={ViewCalendarLayout.WEEK}
|
||||
onEnter={() => {
|
||||
handleCalendarViewChange(ViewCalendarLayout.WEEK);
|
||||
if (isCalendarWeekViewEnabled) {
|
||||
handleCalendarViewChange(ViewCalendarLayout.WEEK);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItemSelect
|
||||
LeftIcon={IconCalendarWeek}
|
||||
text={t`Week`}
|
||||
selected={recordIndexCalendarLayout === ViewCalendarLayout.WEEK}
|
||||
selected={supportedCalendarLayout === ViewCalendarLayout.WEEK}
|
||||
onClick={
|
||||
isCalendarWeekViewEnabled
|
||||
? () => handleCalendarViewChange(ViewCalendarLayout.WEEK)
|
||||
: undefined
|
||||
}
|
||||
focused={selectedItemId === ViewCalendarLayout.WEEK}
|
||||
contextualText={<Pill label={t`Soon`} />}
|
||||
contextualText={
|
||||
isCalendarWeekViewEnabled ? undefined : <Pill label={t`Soon`} />
|
||||
}
|
||||
contextualTextPosition="right"
|
||||
disabled
|
||||
disabled={!isCalendarWeekViewEnabled}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
<SelectableListItem
|
||||
@@ -95,7 +128,7 @@ export const ObjectOptionsDropdownCalendarViewContent = () => {
|
||||
<MenuItemSelect
|
||||
LeftIcon={IconCalendarMonth}
|
||||
text={t`Month`}
|
||||
selected={recordIndexCalendarLayout === ViewCalendarLayout.MONTH}
|
||||
selected={supportedCalendarLayout === ViewCalendarLayout.MONTH}
|
||||
onClick={() => handleCalendarViewChange(ViewCalendarLayout.MONTH)}
|
||||
focused={selectedItemId === ViewCalendarLayout.MONTH}
|
||||
/>
|
||||
|
||||
+12
@@ -1,5 +1,6 @@
|
||||
import { ObjectOptionsDropdownAddRecordGroupContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownAddRecordGroupContent';
|
||||
import { ObjectOptionsDropdownCalendarFieldsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarFieldsContent';
|
||||
import { ObjectOptionsDropdownCalendarEndFieldsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarEndFieldsContent';
|
||||
import { ObjectOptionsDropdownCalendarViewContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarViewContent';
|
||||
import { ObjectOptionsDropdownFieldsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownFieldsContent';
|
||||
import { ObjectOptionsDropdownHiddenFieldsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenFieldsContent';
|
||||
@@ -12,9 +13,14 @@ import { ObjectOptionsDropdownRecordGroupsContent } from '@/object-record/object
|
||||
import { ObjectOptionsDropdownRecordGroupSortContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupSortContent';
|
||||
import { ObjectOptionsDropdownVisibilityContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownVisibilityContent';
|
||||
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
||||
|
||||
export const ObjectOptionsDropdownContent = () => {
|
||||
const { currentContentId } = useObjectOptionsDropdown();
|
||||
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
);
|
||||
|
||||
switch (currentContentId) {
|
||||
case 'layout':
|
||||
@@ -39,6 +45,12 @@ export const ObjectOptionsDropdownContent = () => {
|
||||
return <ObjectOptionsDropdownCalendarViewContent />;
|
||||
case 'calendarFields':
|
||||
return <ObjectOptionsDropdownCalendarFieldsContent />;
|
||||
case 'calendarEndFields':
|
||||
return isCalendarWeekViewEnabled ? (
|
||||
<ObjectOptionsDropdownCalendarEndFieldsContent />
|
||||
) : (
|
||||
<ObjectOptionsDropdownMenuContent />
|
||||
);
|
||||
case 'visibility':
|
||||
return <ObjectOptionsDropdownVisibilityContent />;
|
||||
default:
|
||||
|
||||
+49
-4
@@ -2,6 +2,7 @@ import { ObjectOptionsDropdownMenuViewName } from '@/object-record/object-option
|
||||
import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId';
|
||||
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
|
||||
import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard';
|
||||
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
|
||||
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
|
||||
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
viewTypeIconMapping,
|
||||
} from '@/views/types/ViewType';
|
||||
import { useDestroyViewFromCurrentState } from '@/views/view-picker/hooks/useDestroyViewFromCurrentState';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { viewPickerReferenceViewIdComponentState } from '@/views/view-picker/states/viewPickerReferenceViewIdComponentState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
|
||||
@@ -37,7 +39,10 @@ import {
|
||||
} from 'twenty-ui/icon';
|
||||
import { AppTooltip } from 'twenty-ui/surfaces';
|
||||
import { MenuItem } from 'twenty-ui/navigation';
|
||||
import { ViewCalendarLayout } from '~/generated-metadata/graphql';
|
||||
import {
|
||||
FeatureFlagKey,
|
||||
ViewCalendarLayout,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
interface ObjectOptionsDropdownCustomViewProps {
|
||||
onBackToDefault?: () => void;
|
||||
@@ -70,6 +75,12 @@ export const ObjectOptionsDropdownCustomView = ({
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const calendarEndFieldMetadata = currentView?.calendarEndFieldMetadataId
|
||||
? objectMetadataItem.fields.find(
|
||||
(field) => field.id === currentView.calendarEndFieldMetadataId,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const viewsOnCurrentObject = useAtomFamilySelectorValue(
|
||||
viewsFromObjectMetadataItemFamilySelector,
|
||||
{ objectMetadataItemId: objectMetadataItem.id },
|
||||
@@ -81,6 +92,13 @@ export const ObjectOptionsDropdownCustomView = ({
|
||||
const recordIndexCalendarLayout = useAtomStateValue(
|
||||
recordIndexCalendarLayoutState,
|
||||
);
|
||||
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
);
|
||||
const supportedCalendarLayout = getSupportedRecordCalendarLayout({
|
||||
calendarLayout: recordIndexCalendarLayout,
|
||||
isCalendarWeekViewEnabled,
|
||||
});
|
||||
|
||||
const { visibleBoardFields } = useObjectOptionsForBoard({
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
@@ -111,7 +129,11 @@ export const ObjectOptionsDropdownCustomView = ({
|
||||
'Visibility',
|
||||
'Fields',
|
||||
...(customViewData?.type === ViewType.CALENDAR
|
||||
? ['CalendarDateField', 'CalendarView']
|
||||
? [
|
||||
'CalendarDateField',
|
||||
...(isCalendarWeekViewEnabled ? ['CalendarEndDateField'] : []),
|
||||
'CalendarView',
|
||||
]
|
||||
: []),
|
||||
...(customViewData?.type !== ViewType.CALENDAR ? ['Group'] : []),
|
||||
'Delete view',
|
||||
@@ -196,6 +218,29 @@ export const ObjectOptionsDropdownCustomView = ({
|
||||
/>
|
||||
</SelectableListItem>
|
||||
</div>
|
||||
{isCalendarWeekViewEnabled && (
|
||||
<div id="calendar-end-date-field-picker-menu-item">
|
||||
<SelectableListItem
|
||||
itemId="CalendarEndDateField"
|
||||
onEnter={() => onContentChange('calendarEndFields')}
|
||||
>
|
||||
<MenuItem
|
||||
focused={selectedItemId === 'CalendarEndDateField'}
|
||||
onClick={() => onContentChange('calendarEndFields')}
|
||||
LeftIcon={IconCalendar}
|
||||
text={t`End date field`}
|
||||
contextualText={
|
||||
isDefaultView
|
||||
? t`Not available on Default View`
|
||||
: (calendarEndFieldMetadata?.label ?? t`None`)
|
||||
}
|
||||
contextualTextPosition="right"
|
||||
hasSubMenu
|
||||
disabled={isDefaultView}
|
||||
/>
|
||||
</SelectableListItem>
|
||||
</div>
|
||||
)}
|
||||
<SelectableListItem
|
||||
itemId="CalendarView"
|
||||
onEnter={() => onContentChange('calendarView')}
|
||||
@@ -206,9 +251,9 @@ export const ObjectOptionsDropdownCustomView = ({
|
||||
LeftIcon={IconCalendarWeek}
|
||||
text={t`Calendar view`}
|
||||
contextualText={
|
||||
recordIndexCalendarLayout === ViewCalendarLayout.MONTH
|
||||
supportedCalendarLayout === ViewCalendarLayout.MONTH
|
||||
? t`Month`
|
||||
: recordIndexCalendarLayout === ViewCalendarLayout.WEEK
|
||||
: supportedCalendarLayout === ViewCalendarLayout.WEEK
|
||||
? t`Week`
|
||||
: t`Day`
|
||||
}
|
||||
|
||||
+41
-3
@@ -1,6 +1,7 @@
|
||||
import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId';
|
||||
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
|
||||
import { useSetViewTypeFromLayoutOptionsMenu } from '@/object-record/object-options-dropdown/hooks/useSetViewTypeFromLayoutOptionsMenu';
|
||||
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
|
||||
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
|
||||
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState';
|
||||
import { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState';
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
} from '@/views/types/ViewType';
|
||||
import { useGetAvailableFieldsForCalendar } from '@/views/view-picker/hooks/useGetAvailableFieldsForCalendar';
|
||||
import { useGetAvailableFieldsToGroupRecordsBy } from '@/views/view-picker/hooks/useGetAvailableFieldsToGroupRecordsBy';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useCallback } from 'react';
|
||||
@@ -41,6 +43,7 @@ import {
|
||||
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
|
||||
import { MenuItem, MenuItemSelect, MenuItemToggle } from 'twenty-ui/navigation';
|
||||
import {
|
||||
FeatureFlagKey,
|
||||
ViewCalendarLayout,
|
||||
ViewOpenRecordIn,
|
||||
} from '~/generated-metadata/graphql';
|
||||
@@ -72,6 +75,13 @@ export const ObjectOptionsDropdownLayoutContent = () => {
|
||||
const recordIndexCalendarLayout = useAtomStateValue(
|
||||
recordIndexCalendarLayoutState,
|
||||
);
|
||||
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
);
|
||||
const supportedCalendarLayout = getSupportedRecordCalendarLayout({
|
||||
calendarLayout: recordIndexCalendarLayout,
|
||||
isCalendarWeekViewEnabled,
|
||||
});
|
||||
const recordIndexGroupFieldMetadataItem = useAtomComponentStateValue(
|
||||
recordIndexGroupFieldMetadataItemComponentState,
|
||||
);
|
||||
@@ -82,6 +92,12 @@ export const ObjectOptionsDropdownLayoutContent = () => {
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const calendarEndFieldMetadata = currentView?.calendarEndFieldMetadataId
|
||||
? objectMetadataItem.fields.find(
|
||||
(field) => field.id === currentView.calendarEndFieldMetadataId,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const { setAndPersistViewType } = useSetViewTypeFromLayoutOptionsMenu();
|
||||
const { availableFieldsForGrouping, navigateToSelectSettings } =
|
||||
useGetAvailableFieldsToGroupRecordsBy();
|
||||
@@ -127,7 +143,11 @@ export const ObjectOptionsDropdownLayoutContent = () => {
|
||||
ViewOpenRecordIn.SIDE_PANEL,
|
||||
...(currentView?.type === ViewType.KANBAN ? ['Group'] : []),
|
||||
...(currentView?.type === ViewType.CALENDAR
|
||||
? ['CalendarView', 'CalendarDateField']
|
||||
? [
|
||||
'CalendarView',
|
||||
'CalendarDateField',
|
||||
...(isCalendarWeekViewEnabled ? ['CalendarEndDateField'] : []),
|
||||
]
|
||||
: []),
|
||||
...(currentView?.type !== ViewType.TABLE ? ['Compact view'] : []),
|
||||
];
|
||||
@@ -236,6 +256,24 @@ export const ObjectOptionsDropdownLayoutContent = () => {
|
||||
hasSubMenu
|
||||
/>
|
||||
</SelectableListItem>
|
||||
{isCalendarWeekViewEnabled && (
|
||||
<SelectableListItem
|
||||
itemId="CalendarEndDateField"
|
||||
onEnter={() => onContentChange('calendarEndFields')}
|
||||
>
|
||||
<MenuItem
|
||||
focused={selectedItemId === 'CalendarEndDateField'}
|
||||
onClick={() => onContentChange('calendarEndFields')}
|
||||
LeftIcon={IconCalendar}
|
||||
text={t`End date field`}
|
||||
contextualText={
|
||||
calendarEndFieldMetadata?.label ?? t`None`
|
||||
}
|
||||
contextualTextPosition="right"
|
||||
hasSubMenu
|
||||
/>
|
||||
</SelectableListItem>
|
||||
)}
|
||||
<SelectableListItem
|
||||
itemId="CalendarView"
|
||||
onEnter={() => onContentChange('calendarView')}
|
||||
@@ -246,9 +284,9 @@ export const ObjectOptionsDropdownLayoutContent = () => {
|
||||
LeftIcon={IconCalendarWeek}
|
||||
text={t`Calendar view`}
|
||||
contextualText={
|
||||
recordIndexCalendarLayout === ViewCalendarLayout.MONTH
|
||||
supportedCalendarLayout === ViewCalendarLayout.MONTH
|
||||
? t`Month`
|
||||
: recordIndexCalendarLayout === ViewCalendarLayout.WEEK
|
||||
: supportedCalendarLayout === ViewCalendarLayout.WEEK
|
||||
? t`Week`
|
||||
: t`Day`
|
||||
}
|
||||
|
||||
+2
@@ -97,6 +97,7 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => {
|
||||
...currentView,
|
||||
type: viewType,
|
||||
calendarFieldMetadataId,
|
||||
calendarEndFieldMetadataId: null,
|
||||
calendarLayout: ViewCalendarLayout.MONTH,
|
||||
},
|
||||
objectMetadataItem,
|
||||
@@ -109,6 +110,7 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => {
|
||||
updateCurrentViewParams.calendarLayout = ViewCalendarLayout.MONTH;
|
||||
updateCurrentViewParams.calendarFieldMetadataId =
|
||||
calendarFieldMetadataId;
|
||||
updateCurrentViewParams.calendarEndFieldMetadataId = null;
|
||||
updateCurrentViewParams.mainGroupByFieldMetadataId = null;
|
||||
return await updateCurrentView(updateCurrentViewParams);
|
||||
}
|
||||
|
||||
+1
@@ -9,5 +9,6 @@ export type ObjectOptionsContentId =
|
||||
| 'recordGroupSort'
|
||||
| 'addRecordGroup'
|
||||
| 'calendarFields'
|
||||
| 'calendarEndFields'
|
||||
| 'calendarView'
|
||||
| 'visibility';
|
||||
|
||||
+30
-1
@@ -5,16 +5,26 @@ import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandM
|
||||
import { RecordCalendarTopBar } from '@/object-record/record-calendar/components/RecordCalendarTopBar';
|
||||
import { RECORD_CALENDAR_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-calendar/constants/RecordCalendarClickOutsideListenerId';
|
||||
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';
|
||||
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
|
||||
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
|
||||
import { useRecordCalendarSelection } from '@/object-record/record-calendar/states/selectors/useRecordCalendarSelection';
|
||||
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
|
||||
import { MODAL_BACKDROP_CLICK_OUTSIDE_ID } from '@/ui/layout/modal/constants/ModalBackdropClickOutsideId';
|
||||
import { PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID } from '@/ui/layout/page/constants/PageActionContainerClickOutsideId';
|
||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { useEffect } from 'react';
|
||||
import { LINK_CHIP_CLICK_OUTSIDE_ID } from 'twenty-ui/data-display';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import {
|
||||
FeatureFlagKey,
|
||||
ViewCalendarLayout,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
const StyledContainerContainer = styled.div`
|
||||
box-sizing: border-box;
|
||||
@@ -33,6 +43,21 @@ export const RecordCalendar = () => {
|
||||
|
||||
const { resetRecordSelection } = useRecordCalendarSelection(recordCalendarId);
|
||||
|
||||
const recordIndexCalendarLayout = useAtomStateValue(
|
||||
recordIndexCalendarLayoutState,
|
||||
);
|
||||
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
);
|
||||
const supportedCalendarLayout = getSupportedRecordCalendarLayout({
|
||||
calendarLayout: recordIndexCalendarLayout,
|
||||
isCalendarWeekViewEnabled,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
resetRecordSelection();
|
||||
}, [resetRecordSelection, supportedCalendarLayout]);
|
||||
|
||||
useListenClickOutside({
|
||||
excludedClickOutsideIds: [
|
||||
COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID,
|
||||
@@ -55,7 +80,11 @@ export const RecordCalendar = () => {
|
||||
<ScrollWrapper
|
||||
componentInstanceId={`scroll-wrapper-record-calendar-${recordCalendarId}`}
|
||||
>
|
||||
<RecordCalendarMonth />
|
||||
{supportedCalendarLayout === ViewCalendarLayout.WEEK ? (
|
||||
<RecordCalendarWeek />
|
||||
) : (
|
||||
<RecordCalendarMonth />
|
||||
)}
|
||||
</ScrollWrapper>
|
||||
</StyledContainerContainer>
|
||||
);
|
||||
|
||||
+63
-9
@@ -2,6 +2,7 @@ import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPe
|
||||
import { isFieldMetadataReadOnlyByPermissions } from '@/object-record/read-only/utils/internal/isFieldMetadataReadOnlyByPermissions';
|
||||
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
|
||||
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
|
||||
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
|
||||
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
|
||||
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
|
||||
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
|
||||
@@ -9,24 +10,30 @@ import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUs
|
||||
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useContext } from 'react';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { IconPlus } from 'twenty-ui/icon';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledButtonContainer = styled.div`
|
||||
const StyledButtonContainer = styled.div<{ compact: boolean }>`
|
||||
height: auto;
|
||||
min-width: unset;
|
||||
padding: ${themeCssVariables.spacing['0.5']};
|
||||
padding: ${({ compact }) => (compact ? 0 : themeCssVariables.spacing['0.5'])};
|
||||
`;
|
||||
|
||||
type RecordCalendarAddNewProps = {
|
||||
cardDate: Temporal.PlainDate;
|
||||
cardTime?: Temporal.PlainTime;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export const RecordCalendarAddNew = ({
|
||||
cardDate,
|
||||
cardTime,
|
||||
compact = false,
|
||||
}: RecordCalendarAddNewProps) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { userTimezone } = useUserTimezone();
|
||||
@@ -46,18 +53,33 @@ export const RecordCalendarAddNew = ({
|
||||
const recordIndexCalendarFieldMetadataId = useAtomStateValue(
|
||||
recordIndexCalendarFieldMetadataIdState,
|
||||
);
|
||||
const recordIndexCalendarEndFieldMetadataId = useAtomStateValue(
|
||||
recordIndexCalendarEndFieldMetadataIdState,
|
||||
);
|
||||
|
||||
const calendarFieldMetadataItem = objectMetadataItem.fields.find(
|
||||
(field) => field.id === recordIndexCalendarFieldMetadataId,
|
||||
);
|
||||
const calendarEndFieldMetadataItem = objectMetadataItem.fields.find(
|
||||
(field) => field.id === recordIndexCalendarEndFieldMetadataId,
|
||||
);
|
||||
|
||||
const isCalendarFieldReadOnly = calendarFieldMetadataItem
|
||||
? isFieldMetadataReadOnlyByPermissions({
|
||||
? calendarFieldMetadataItem.isUIEditable === false ||
|
||||
isFieldMetadataReadOnlyByPermissions({
|
||||
objectPermissions,
|
||||
fieldMetadataId: calendarFieldMetadataItem.id,
|
||||
})
|
||||
: false;
|
||||
|
||||
const isCalendarEndFieldReadOnly = calendarEndFieldMetadataItem
|
||||
? calendarEndFieldMetadataItem.isUIEditable === false ||
|
||||
isFieldMetadataReadOnlyByPermissions({
|
||||
objectPermissions,
|
||||
fieldMetadataId: calendarEndFieldMetadataItem.id,
|
||||
})
|
||||
: false;
|
||||
|
||||
if (
|
||||
hasAnySoftDeleteFilterOnView === true ||
|
||||
!canCreateRecordsForObjectMetadataItem({
|
||||
@@ -70,17 +92,49 @@ export const RecordCalendarAddNew = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const createRecordAriaLabel = cardTime
|
||||
? t`Create record on ${cardDate.toLocaleString(undefined, {
|
||||
dateStyle: 'full',
|
||||
})} at ${cardTime.toLocaleString(undefined, { timeStyle: 'short' })}`
|
||||
: t`Create record`;
|
||||
|
||||
return (
|
||||
<StyledButtonContainer>
|
||||
<StyledButtonContainer compact={compact}>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
ariaLabel={createRecordAriaLabel}
|
||||
onClick={async (event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
const startDateTime = cardDate.toZonedDateTime({
|
||||
timeZone: userTimezone,
|
||||
plainTime: cardTime,
|
||||
});
|
||||
const startValue =
|
||||
calendarFieldMetadataItem.type === FieldMetadataType.DATE
|
||||
? cardDate.toString()
|
||||
: startDateTime.toInstant().toString();
|
||||
|
||||
await createNewIndexRecord({
|
||||
[calendarFieldMetadataItem.name]: cardDate
|
||||
.toZonedDateTime(userTimezone)
|
||||
.toInstant()
|
||||
.toString(),
|
||||
[calendarFieldMetadataItem.name]: startValue,
|
||||
...(calendarFieldMetadataItem.type === FieldMetadataType.DATE &&
|
||||
isCalendarEndFieldReadOnly === false &&
|
||||
calendarEndFieldMetadataItem?.type === FieldMetadataType.DATE && {
|
||||
[calendarEndFieldMetadataItem.name]: cardDate.toString(),
|
||||
}),
|
||||
...(calendarFieldMetadataItem.type ===
|
||||
FieldMetadataType.DATE_TIME &&
|
||||
isCalendarEndFieldReadOnly === false &&
|
||||
calendarEndFieldMetadataItem?.type ===
|
||||
FieldMetadataType.DATE_TIME && {
|
||||
[calendarEndFieldMetadataItem.name]: startDateTime
|
||||
.add({ hours: 1 })
|
||||
.toInstant()
|
||||
.toString(),
|
||||
}),
|
||||
});
|
||||
}}
|
||||
size={compact ? 'small' : 'medium'}
|
||||
type="button"
|
||||
variant="tertiary"
|
||||
Icon={() => <IconPlus size={theme.icon.size.sm} />}
|
||||
/>
|
||||
|
||||
+83
-11
@@ -1,7 +1,12 @@
|
||||
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';
|
||||
import { useRecordCalendarWeekDaysRange } from '@/object-record/record-calendar/week/hooks/useRecordCalendarWeekDaysRange';
|
||||
import { formatRecordCalendarWeekRange } from '@/object-record/record-calendar/week/utils/formatRecordCalendarWeekRange';
|
||||
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
|
||||
import { DatePickerWithoutCalendar } from '@/ui/input/components/internal/date/components/DatePickerWithoutCalendar';
|
||||
import { TimeZoneAbbreviation } from '@/ui/input/components/internal/date/components/TimeZoneAbbreviation';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { SelectControl } from '@/ui/input/components/SelectControl';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
@@ -9,6 +14,10 @@ import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
|
||||
import { type DropdownOffset } from '@/ui/layout/dropdown/types/DropdownOffset';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useUpdateCurrentView } from '@/views/hooks/useUpdateCurrentView';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { format } from 'date-fns';
|
||||
@@ -21,6 +30,11 @@ import {
|
||||
import { IconChevronLeft, IconChevronRight } from 'twenty-ui/icon';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import {
|
||||
FeatureFlagKey,
|
||||
ViewCalendarLayout,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { dateLocaleState } from '~/localization/states/dateLocaleState';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
@@ -55,6 +69,23 @@ export const RecordCalendarTopBar = () => {
|
||||
const [recordCalendarSelectedDate, setRecordCalendarSelectedDate] =
|
||||
useAtomComponentState(recordCalendarSelectedDateComponentState);
|
||||
|
||||
const [recordIndexCalendarLayout, setRecordIndexCalendarLayout] =
|
||||
useAtomState(recordIndexCalendarLayoutState);
|
||||
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
);
|
||||
const supportedCalendarLayout = getSupportedRecordCalendarLayout({
|
||||
calendarLayout: recordIndexCalendarLayout,
|
||||
isCalendarWeekViewEnabled,
|
||||
});
|
||||
|
||||
const dateLocale = useAtomStateValue(dateLocaleState);
|
||||
const { firstDayOfWeek, lastDayOfWeek } = useRecordCalendarWeekDaysRange(
|
||||
recordCalendarSelectedDate,
|
||||
);
|
||||
|
||||
const { updateCurrentView } = useUpdateCurrentView();
|
||||
|
||||
const datePickerDropdownId = `record-calendar-date-picker-${recordCalendarId}`;
|
||||
const { closeDropdown } = useCloseDropdown();
|
||||
|
||||
@@ -65,32 +96,71 @@ export const RecordCalendarTopBar = () => {
|
||||
closeDropdown(datePickerDropdownId);
|
||||
};
|
||||
|
||||
const handlePreviousMonth = () => {
|
||||
const handlePreviousPeriod = () => {
|
||||
setRecordCalendarSelectedDate(
|
||||
recordCalendarSelectedDate.subtract({ months: 1 }),
|
||||
supportedCalendarLayout === ViewCalendarLayout.WEEK
|
||||
? recordCalendarSelectedDate.subtract({ weeks: 1 })
|
||||
: recordCalendarSelectedDate.subtract({ months: 1 }),
|
||||
);
|
||||
};
|
||||
|
||||
const handleNextMonth = () => {
|
||||
const handleNextPeriod = () => {
|
||||
setRecordCalendarSelectedDate(
|
||||
recordCalendarSelectedDate?.add({ months: 1 }),
|
||||
supportedCalendarLayout === ViewCalendarLayout.WEEK
|
||||
? recordCalendarSelectedDate.add({ weeks: 1 })
|
||||
: recordCalendarSelectedDate.add({ months: 1 }),
|
||||
);
|
||||
};
|
||||
|
||||
const handleCalendarLayoutChange = (calendarLayout: ViewCalendarLayout) => {
|
||||
if (
|
||||
calendarLayout === ViewCalendarLayout.WEEK &&
|
||||
!isCalendarWeekViewEnabled
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRecordIndexCalendarLayout(calendarLayout);
|
||||
void updateCurrentView({ calendarLayout });
|
||||
};
|
||||
|
||||
const handleTodayClick = () => {
|
||||
setRecordCalendarSelectedDate(Temporal.Now.plainDateISO());
|
||||
};
|
||||
|
||||
const formattedDate = format(
|
||||
turnPlainDateToShiftedDateInSystemTimeZone(recordCalendarSelectedDate),
|
||||
'MMMM yyyy',
|
||||
);
|
||||
const formattedDate =
|
||||
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;
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledLeftSection>
|
||||
{isCalendarWeekViewEnabled && (
|
||||
<Select
|
||||
dropdownId={`record-calendar-layout-${recordCalendarId}`}
|
||||
value={supportedCalendarLayout}
|
||||
options={[
|
||||
{ label: t`Week`, value: ViewCalendarLayout.WEEK },
|
||||
{ label: t`Month`, value: ViewCalendarLayout.MONTH },
|
||||
]}
|
||||
selectSizeVariant="small"
|
||||
dropdownWidth={120}
|
||||
onChange={handleCalendarLayoutChange}
|
||||
/>
|
||||
)}
|
||||
<Dropdown
|
||||
dropdownId={datePickerDropdownId}
|
||||
clickableComponent={
|
||||
@@ -116,7 +186,9 @@ export const RecordCalendarTopBar = () => {
|
||||
}
|
||||
dropdownOffset={dropdownContentOffset}
|
||||
/>
|
||||
<TimeZoneAbbreviation instant={Temporal.Now.instant()} />
|
||||
{supportedCalendarLayout !== ViewCalendarLayout.WEEK && (
|
||||
<TimeZoneAbbreviation instant={Temporal.Now.instant()} />
|
||||
)}
|
||||
</StyledLeftSection>
|
||||
|
||||
<StyledNavigationSection>
|
||||
@@ -125,7 +197,7 @@ export const RecordCalendarTopBar = () => {
|
||||
size="small"
|
||||
variant="tertiary"
|
||||
Icon={IconChevronLeft}
|
||||
onClick={handlePreviousMonth}
|
||||
onClick={handlePreviousPeriod}
|
||||
/>
|
||||
</StyledNavigationButtonContainer>
|
||||
<Button
|
||||
@@ -139,7 +211,7 @@ export const RecordCalendarTopBar = () => {
|
||||
size="small"
|
||||
variant="tertiary"
|
||||
Icon={IconChevronRight}
|
||||
onClick={handleNextMonth}
|
||||
onClick={handleNextPeriod}
|
||||
/>
|
||||
</StyledNavigationButtonContainer>
|
||||
</StyledNavigationSection>
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { RecordCalendar } from '@/object-record/record-calendar/components/RecordCalendar';
|
||||
import {
|
||||
FeatureFlagKey,
|
||||
ViewCalendarLayout,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
jest.mock(
|
||||
'@/object-record/record-calendar/components/RecordCalendarTopBar',
|
||||
() => ({
|
||||
RecordCalendarTopBar: () => <div data-testid="calendar-top-bar" />,
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'@/object-record/record-calendar/month/components/RecordCalendarMonth',
|
||||
() => ({
|
||||
RecordCalendarMonth: () => <div data-testid="calendar-month" />,
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'@/object-record/record-calendar/week/components/RecordCalendarWeek',
|
||||
() => ({
|
||||
RecordCalendarWeek: () => <div data-testid="calendar-week" />,
|
||||
}),
|
||||
);
|
||||
jest.mock('@/ui/utilities/scroll/components/ScrollWrapper', () => ({
|
||||
ScrollWrapper: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
jest.mock(
|
||||
'@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow',
|
||||
() => ({
|
||||
useAvailableComponentInstanceIdOrThrow: jest.fn(() => 'calendar-id'),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'@/object-record/record-calendar/states/selectors/useRecordCalendarSelection',
|
||||
() => ({
|
||||
useRecordCalendarSelection: jest.fn(() => ({
|
||||
resetRecordSelection: jest.fn(),
|
||||
})),
|
||||
}),
|
||||
);
|
||||
jest.mock('@/ui/utilities/pointer-event/hooks/useListenClickOutside', () => ({
|
||||
useListenClickOutside: jest.fn(),
|
||||
}));
|
||||
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomStateValue', () => ({
|
||||
useAtomStateValue: jest.fn(),
|
||||
}));
|
||||
jest.mock('@/workspace/hooks/useIsFeatureEnabled', () => ({
|
||||
useIsFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
const useAtomStateValueMock = jest.requireMock(
|
||||
'@/ui/utilities/state/jotai/hooks/useAtomStateValue',
|
||||
).useAtomStateValue;
|
||||
const useIsFeatureEnabledMock = jest.requireMock(
|
||||
'@/workspace/hooks/useIsFeatureEnabled',
|
||||
).useIsFeatureEnabled;
|
||||
|
||||
describe('RecordCalendar', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useAtomStateValueMock.mockReturnValue(ViewCalendarLayout.WEEK);
|
||||
});
|
||||
|
||||
it('renders month when a persisted week layout is disabled', () => {
|
||||
useIsFeatureEnabledMock.mockReturnValue(false);
|
||||
|
||||
render(<RecordCalendar />);
|
||||
|
||||
expect(screen.getByTestId('calendar-month')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('calendar-week')).not.toBeInTheDocument();
|
||||
expect(useIsFeatureEnabledMock).toHaveBeenCalledWith(
|
||||
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders week when the week layout is enabled', () => {
|
||||
useIsFeatureEnabledMock.mockReturnValue(true);
|
||||
|
||||
render(<RecordCalendar />);
|
||||
|
||||
expect(screen.getByTestId('calendar-week')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('calendar-month')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const RECORD_CALENDAR_MONTH_VISIBLE_RECORD_LIMIT = 5;
|
||||
+8
-1
@@ -5,6 +5,7 @@ import { useGroupByRecordsQuery } from '@/object-record/hooks/useGroupByRecordsQ
|
||||
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
|
||||
import { useRecordCalendarQueryDateRangeFilter } from '@/object-record/record-calendar/month/hooks/useRecordCalendarQueryDateRangeFilter';
|
||||
import { useRelevantRecordsGqlFields } from '@/object-record/record-field/hooks/useRelevantRecordsGqlFields';
|
||||
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
|
||||
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { buildGroupByFieldObject } from '@/page-layout/widgets/graph/utils/buildGroupByFieldObject';
|
||||
@@ -29,10 +30,16 @@ export const useRecordCalendarGroupByRecords = (
|
||||
const recordIndexCalendarFieldMetadataId = useAtomStateValue(
|
||||
recordIndexCalendarFieldMetadataIdState,
|
||||
);
|
||||
const recordIndexCalendarEndFieldMetadataId = useAtomStateValue(
|
||||
recordIndexCalendarEndFieldMetadataIdState,
|
||||
);
|
||||
|
||||
const recordGqlFields = useRelevantRecordsGqlFields({
|
||||
objectMetadataItem,
|
||||
additionalFieldMetadataId: recordIndexCalendarFieldMetadataId,
|
||||
additionalFieldMetadataIds: [
|
||||
recordIndexCalendarFieldMetadataId,
|
||||
recordIndexCalendarEndFieldMetadataId,
|
||||
],
|
||||
});
|
||||
|
||||
const { dateRangeFilter } =
|
||||
|
||||
+10
-1
@@ -3,6 +3,7 @@ import { RecordCalendarMonthHeader } from '@/object-record/record-calendar/month
|
||||
import { RecordCalendarMonthContextProvider } from '@/object-record/record-calendar/month/contexts/RecordCalendarMonthContext';
|
||||
import { useRecordCalendarMonthDaysRange } from '@/object-record/record-calendar/month/hooks/useRecordCalendarMonthDaysRange';
|
||||
import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState';
|
||||
import { getRecordIdFromRecordCalendarCardDraggableId } from '@/object-record/record-calendar/record-calendar-card/utils/getRecordCalendarCardDraggableId';
|
||||
import { useEndRecordDrag } from '@/object-record/record-drag/hooks/useEndRecordDrag';
|
||||
import { useProcessCalendarCardDrop } from '@/object-record/record-drag/hooks/useProcessCalendarCardDrop';
|
||||
import { useStartRecordDrag } from '@/object-record/record-drag/hooks/useStartRecordDrag';
|
||||
@@ -46,7 +47,15 @@ export const RecordCalendarMonth = () => {
|
||||
} = useRecordCalendarMonthDaysRange(recordCalendarSelectedDate);
|
||||
|
||||
const handleDragStart = (start: DragStart) => {
|
||||
startRecordDrag(start, []);
|
||||
startRecordDrag(
|
||||
{
|
||||
...start,
|
||||
draggableId: getRecordIdFromRecordCalendarCardDraggableId(
|
||||
start.draggableId,
|
||||
),
|
||||
},
|
||||
[],
|
||||
);
|
||||
};
|
||||
|
||||
const handleDragEnd: OnDragEndResponder = (result) => {
|
||||
|
||||
+11
-7
@@ -1,4 +1,5 @@
|
||||
import { RecordCalendarCardDraggableContainer } from '@/object-record/record-calendar/record-calendar-card/components/RecordCalendarCardDraggableContainer';
|
||||
import { RECORD_CALENDAR_MONTH_VISIBLE_RECORD_LIMIT } from '@/object-record/record-calendar/constants/RecordCalendarMonthVisibleRecordLimit';
|
||||
import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState';
|
||||
import { calendarDayRecordIdsComponentFamilySelector } from '@/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
@@ -151,13 +152,16 @@ export const RecordCalendarMonthBodyDay = ({
|
||||
ref={droppableProvided.innerRef}
|
||||
isDraggedOver={droppableSnapshot.isDraggingOver}
|
||||
>
|
||||
{recordIds.slice(0, 5).map((recordId, index) => (
|
||||
<RecordCalendarCardDraggableContainer
|
||||
key={recordId}
|
||||
recordId={recordId}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
{recordIds
|
||||
.slice(0, RECORD_CALENDAR_MONTH_VISIBLE_RECORD_LIMIT)
|
||||
.map((recordId, index) => (
|
||||
<RecordCalendarCardDraggableContainer
|
||||
key={`${recordId}-${dayKey}`}
|
||||
calendarDay={dayKey}
|
||||
recordId={recordId}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
{droppableProvided.placeholder}
|
||||
</StyledCardsContainer>
|
||||
)}
|
||||
|
||||
+4
-6
@@ -26,14 +26,12 @@ export const useRecordCalendarMonthDaysRange = (
|
||||
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
|
||||
const dateLocale = useAtomStateValue(dateLocaleState);
|
||||
|
||||
if (!currentWorkspaceMember) {
|
||||
throw new Error('Current workspace member not found');
|
||||
}
|
||||
|
||||
const calendarStartDay =
|
||||
currentWorkspaceMember?.calendarStartDay ?? CalendarStartDay.SYSTEM;
|
||||
const weekStartsOnDayIndex = (
|
||||
currentWorkspaceMember?.calendarStartDay === CalendarStartDay.SYSTEM
|
||||
calendarStartDay === CalendarStartDay.SYSTEM
|
||||
? CalendarStartDay[detectCalendarStartDay()]
|
||||
: (currentWorkspaceMember?.calendarStartDay ?? 0)
|
||||
: calendarStartDay
|
||||
) as 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
const firstDayOfMonth = selectedDate.with({ day: 1 });
|
||||
|
||||
+39
-6
@@ -1,6 +1,8 @@
|
||||
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
|
||||
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
|
||||
import { useRecordCalendarMonthDaysRange } from '@/object-record/record-calendar/month/hooks/useRecordCalendarMonthDaysRange';
|
||||
import { getRecordCalendarDateRangeOverlapFilter } from '@/object-record/record-calendar/month/utils/getRecordCalendarDateRangeOverlapFilter';
|
||||
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
|
||||
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
|
||||
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
|
||||
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
turnAnyFieldFilterIntoRecordGqlFilter,
|
||||
turnPlainDateIntoUserTimeZoneInstantString,
|
||||
} from 'twenty-shared/utils';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
const DATE_RANGE_FILTER_AFTER_ID = 'DATE_RANGE_FILTER_AFTER_ID';
|
||||
const DATE_RANGE_FILTER_BEFORE_ID = 'DATE_RANGE_FILTER_BEFORE_ID';
|
||||
@@ -51,6 +54,9 @@ export const useRecordCalendarQueryDateRangeFilter = (
|
||||
const flattenedFieldMetadataItems = useAtomStateValue(
|
||||
flattenedFieldMetadataItemsSelector,
|
||||
);
|
||||
const recordIndexCalendarEndFieldMetadataId = useAtomStateValue(
|
||||
recordIndexCalendarEndFieldMetadataIdState,
|
||||
);
|
||||
|
||||
const anyFieldFilterValue = useAtomComponentStateValue(
|
||||
anyFieldFilterValueComponentState,
|
||||
@@ -80,6 +86,31 @@ export const useRecordCalendarQueryDateRangeFilter = (
|
||||
|
||||
const dateRangeFilterFieldMetadataId = currentView.calendarFieldMetadataId;
|
||||
|
||||
const calendarFieldMetadataItem = objectMetadataItem.fields.find(
|
||||
(fieldMetadataItem) =>
|
||||
fieldMetadataItem.id === currentView.calendarFieldMetadataId,
|
||||
);
|
||||
|
||||
const calendarEndFieldMetadataItem = objectMetadataItem.fields.find(
|
||||
(fieldMetadataItem) =>
|
||||
fieldMetadataItem.id === recordIndexCalendarEndFieldMetadataId,
|
||||
);
|
||||
|
||||
const dateRangeOverlapFilter = isDefined(calendarFieldMetadataItem)
|
||||
? getRecordCalendarDateRangeOverlapFilter({
|
||||
calendarField: calendarFieldMetadataItem,
|
||||
calendarEndField: calendarEndFieldMetadataItem,
|
||||
firstDayOfRange:
|
||||
calendarFieldMetadataItem.type === FieldMetadataType.DATE_TIME
|
||||
? firstDayOfFirstWeekISOString
|
||||
: firstDayOfFirstWeek.toString(),
|
||||
nextDayAfterLastDayOfRange:
|
||||
calendarFieldMetadataItem.type === FieldMetadataType.DATE_TIME
|
||||
? nextDayAfterLastDayOfLastWeekISOString
|
||||
: lastDayOfLastWeek.add({ days: 1 }).toString(),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const dateRangeFilterAfter: RecordFilter = {
|
||||
id: DATE_RANGE_FILTER_AFTER_ID,
|
||||
fieldMetadataId: dateRangeFilterFieldMetadataId,
|
||||
@@ -100,11 +131,9 @@ export const useRecordCalendarQueryDateRangeFilter = (
|
||||
displayValue: `${lastDayOfLastWeek.toString()}`,
|
||||
};
|
||||
|
||||
const calendarRecordFilters = [
|
||||
...currentRecordFilters,
|
||||
dateRangeFilterAfter,
|
||||
dateRangeFilterBefore,
|
||||
];
|
||||
const calendarRecordFilters = isDefined(dateRangeOverlapFilter)
|
||||
? currentRecordFilters
|
||||
: [...currentRecordFilters, dateRangeFilterAfter, dateRangeFilterBefore];
|
||||
|
||||
const dateRangeFilter = computeRecordGqlOperationFilter({
|
||||
filterValueDependencies,
|
||||
@@ -119,7 +148,11 @@ export const useRecordCalendarQueryDateRangeFilter = (
|
||||
filterValue: anyFieldFilterValue,
|
||||
});
|
||||
|
||||
const combinedFilter = combineFilters([dateRangeFilter, anyFieldFilter]);
|
||||
const combinedFilter = combineFilters([
|
||||
dateRangeFilter,
|
||||
dateRangeOverlapFilter ?? {},
|
||||
anyFieldFilter,
|
||||
]);
|
||||
|
||||
return {
|
||||
dateRangeFilter: combinedFilter,
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import { getRecordCalendarDateRangeOverlapFilter } from '@/object-record/record-calendar/month/utils/getRecordCalendarDateRangeOverlapFilter';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
describe('getRecordCalendarDateRangeOverlapFilter', () => {
|
||||
it('matches DATE ranges that overlap the visible range', () => {
|
||||
expect(
|
||||
getRecordCalendarDateRangeOverlapFilter({
|
||||
calendarField: {
|
||||
name: 'startDate',
|
||||
type: FieldMetadataType.DATE,
|
||||
},
|
||||
calendarEndField: {
|
||||
name: 'endDate',
|
||||
type: FieldMetadataType.DATE,
|
||||
},
|
||||
firstDayOfRange: '2026-06-29',
|
||||
nextDayAfterLastDayOfRange: '2026-08-10',
|
||||
}),
|
||||
).toEqual({
|
||||
and: [
|
||||
{
|
||||
startDate: {
|
||||
lt: '2026-08-10',
|
||||
},
|
||||
},
|
||||
{
|
||||
or: [
|
||||
{
|
||||
startDate: {
|
||||
gte: '2026-06-29',
|
||||
},
|
||||
},
|
||||
{
|
||||
endDate: {
|
||||
gte: '2026-06-29',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when no end field is configured', () => {
|
||||
expect(
|
||||
getRecordCalendarDateRangeOverlapFilter({
|
||||
calendarField: {
|
||||
name: 'startDate',
|
||||
type: FieldMetadataType.DATE,
|
||||
},
|
||||
calendarEndField: undefined,
|
||||
firstDayOfRange: '2026-06-29',
|
||||
nextDayAfterLastDayOfRange: '2026-08-10',
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('matches DATE_TIME ranges that overlap the visible range', () => {
|
||||
expect(
|
||||
getRecordCalendarDateRangeOverlapFilter({
|
||||
calendarField: {
|
||||
name: 'startsAt',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
},
|
||||
calendarEndField: {
|
||||
name: 'endsAt',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
},
|
||||
firstDayOfRange: '2026-06-28T22:00:00Z',
|
||||
nextDayAfterLastDayOfRange: '2026-08-09T22:00:00Z',
|
||||
}),
|
||||
).toEqual({
|
||||
and: [
|
||||
{
|
||||
startsAt: {
|
||||
lt: '2026-08-09T22:00:00Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
or: [
|
||||
{
|
||||
startsAt: {
|
||||
gte: '2026-06-28T22:00:00Z',
|
||||
},
|
||||
},
|
||||
{
|
||||
endsAt: {
|
||||
gte: '2026-06-28T22:00:00Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined for incompatible calendar field types', () => {
|
||||
expect(
|
||||
getRecordCalendarDateRangeOverlapFilter({
|
||||
calendarField: {
|
||||
name: 'startsAt',
|
||||
type: FieldMetadataType.DATE_TIME,
|
||||
},
|
||||
calendarEndField: {
|
||||
name: 'endDate',
|
||||
type: FieldMetadataType.DATE,
|
||||
},
|
||||
firstDayOfRange: '2026-06-28T22:00:00Z',
|
||||
nextDayAfterLastDayOfRange: '2026-08-09T22:00:00Z',
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
FieldMetadataType,
|
||||
type RecordGqlOperationFilter,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
type CalendarDateField = {
|
||||
name: string;
|
||||
type: FieldMetadataType;
|
||||
};
|
||||
|
||||
type GetRecordCalendarDateRangeOverlapFilterParams = {
|
||||
calendarField: CalendarDateField;
|
||||
calendarEndField: CalendarDateField | undefined;
|
||||
firstDayOfRange: string;
|
||||
nextDayAfterLastDayOfRange: string;
|
||||
};
|
||||
|
||||
export const getRecordCalendarDateRangeOverlapFilter = ({
|
||||
calendarField,
|
||||
calendarEndField,
|
||||
firstDayOfRange,
|
||||
nextDayAfterLastDayOfRange,
|
||||
}: GetRecordCalendarDateRangeOverlapFilterParams):
|
||||
| RecordGqlOperationFilter
|
||||
| undefined => {
|
||||
const hasCompatibleDateRangeFields =
|
||||
(calendarField.type === FieldMetadataType.DATE &&
|
||||
calendarEndField?.type === FieldMetadataType.DATE) ||
|
||||
(calendarField.type === FieldMetadataType.DATE_TIME &&
|
||||
calendarEndField?.type === FieldMetadataType.DATE_TIME);
|
||||
|
||||
if (!hasCompatibleDateRangeFields) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
and: [
|
||||
{
|
||||
[calendarField.name]: {
|
||||
lt: nextDayAfterLastDayOfRange,
|
||||
},
|
||||
},
|
||||
{
|
||||
or: [
|
||||
{
|
||||
[calendarField.name]: {
|
||||
gte: firstDayOfRange,
|
||||
},
|
||||
},
|
||||
{
|
||||
[calendarEndField.name]: {
|
||||
gte: firstDayOfRange,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
+18
@@ -11,10 +11,13 @@ import { RecordCalendarCardComponentInstanceContext } from '@/object-record/reco
|
||||
import { isRecordCalendarCardSelectedComponentFamilyState } from '@/object-record/record-calendar/record-calendar-card/states/isRecordCalendarCardSelectedComponentFamilyState';
|
||||
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
|
||||
import { RecordCard } from '@/object-record/record-card/components/RecordCard';
|
||||
import { isDraggingRecordComponentState } from '@/object-record/record-drag/states/isDraggingRecordComponentState';
|
||||
import { RecordFieldsScopeContextProvider } from '@/object-record/record-field-list/contexts/RecordFieldsScopeContext';
|
||||
import { useOpenRecordFromIndexView } from '@/object-record/record-index/hooks/useOpenRecordFromIndexView';
|
||||
import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
|
||||
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
|
||||
import { useAtomComponentFamilyState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyState';
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
|
||||
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
|
||||
import { styled } from '@linaria/react';
|
||||
@@ -36,6 +39,12 @@ export const RecordCalendarCard = ({ recordId }: RecordCalendarCardProps) => {
|
||||
const { currentView } = useGetCurrentViewOnly();
|
||||
|
||||
const isCompactModeActive = currentView?.isCompact ?? false;
|
||||
const isDraggingRecord = useAtomComponentStateValue(
|
||||
isDraggingRecordComponentState,
|
||||
);
|
||||
|
||||
const { openRecordFromIndexView } = useOpenRecordFromIndexView();
|
||||
|
||||
const [isRecordCalendarCardSelected, setIsRecordCalendarCardSelected] =
|
||||
useAtomComponentFamilyState(
|
||||
isRecordCalendarCardSelectedComponentFamilyState,
|
||||
@@ -58,6 +67,14 @@ export const RecordCalendarCard = ({ recordId }: RecordCalendarCardProps) => {
|
||||
|
||||
const { openDropdown } = useOpenDropdown();
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (isDraggingRecord) {
|
||||
return;
|
||||
}
|
||||
|
||||
openRecordFromIndexView({ recordId });
|
||||
};
|
||||
|
||||
const handleContextMenuOpen = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
setIsRecordCalendarCardSelected(true);
|
||||
@@ -88,6 +105,7 @@ export const RecordCalendarCard = ({ recordId }: RecordCalendarCardProps) => {
|
||||
<RecordCard
|
||||
data-selected={isRecordCalendarCardSelected}
|
||||
data-click-outside-id={RECORD_CALENDAR_CARD_CLICK_OUTSIDE_ID}
|
||||
onClick={isCompactModeActive ? handleCardClick : undefined}
|
||||
>
|
||||
<RecordCalendarCardHeader recordId={recordId} />
|
||||
<AnimatedEaseInOut isOpen={!isCompactModeActive} initial={false}>
|
||||
|
||||
+10
-44
@@ -2,15 +2,10 @@ import { styled } from '@linaria/react';
|
||||
import { Draggable } from '@hello-pangea/dnd';
|
||||
|
||||
import { getCssCompatibleDraggableProps } from '@/ui/layout/draggable-list/utils/getCssCompatibleDraggableProps';
|
||||
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
|
||||
import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly';
|
||||
import { isFieldMetadataReadOnlyByPermissions } from '@/object-record/read-only/utils/internal/isFieldMetadataReadOnlyByPermissions';
|
||||
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
|
||||
import { RecordCalendarCard } from '@/object-record/record-calendar/record-calendar-card/components/RecordCalendarCard';
|
||||
import { useIsRecordCalendarCardDragDisabled } from '@/object-record/record-calendar/record-calendar-card/hooks/useIsRecordCalendarCardDragDisabled';
|
||||
import { RecordCalendarCardComponentInstanceContext } from '@/object-record/record-calendar/record-calendar-card/states/contexts/RecordCalendarCardComponentInstanceContext';
|
||||
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { getRecordCalendarCardDraggableId } from '@/object-record/record-calendar/record-calendar-card/utils/getRecordCalendarCardDraggableId';
|
||||
|
||||
const StyledDraggableContainer = styled.div`
|
||||
position: relative;
|
||||
@@ -20,63 +15,34 @@ const StyledDraggableContainer = styled.div`
|
||||
`;
|
||||
|
||||
export const RecordCalendarCardDraggableContainer = ({
|
||||
calendarDay,
|
||||
recordId,
|
||||
index,
|
||||
}: {
|
||||
calendarDay: string;
|
||||
recordId: string;
|
||||
index: number;
|
||||
}) => {
|
||||
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
|
||||
const dragIsDisabled = useIsRecordCalendarCardDragDisabled(recordId);
|
||||
|
||||
const recordIsReadOnly = useIsRecordReadOnly({
|
||||
const draggableId = getRecordCalendarCardDraggableId({
|
||||
calendarDay,
|
||||
recordId,
|
||||
objectMetadataId: objectMetadataItem.id,
|
||||
});
|
||||
|
||||
const objectPermissions = useObjectPermissionsForObject(
|
||||
objectMetadataItem.id,
|
||||
);
|
||||
|
||||
const recordIndexCalendarFieldMetadataId = useAtomStateValue(
|
||||
recordIndexCalendarFieldMetadataIdState,
|
||||
);
|
||||
|
||||
const calendarFieldMetadataItem = objectMetadataItem.fields.find(
|
||||
(field) => field.id === recordIndexCalendarFieldMetadataId,
|
||||
);
|
||||
|
||||
const calendarFieldMetadataItemIsUIReadOnly =
|
||||
calendarFieldMetadataItem?.isUIEditable === false;
|
||||
|
||||
const calendarFieldMetadataItemIsRestrictedForUpdate = isDefined(
|
||||
calendarFieldMetadataItem,
|
||||
)
|
||||
? isFieldMetadataReadOnlyByPermissions({
|
||||
objectPermissions,
|
||||
fieldMetadataId: calendarFieldMetadataItem.id,
|
||||
})
|
||||
: false;
|
||||
|
||||
const calendarFieldMetadataItemIsReadOnly =
|
||||
calendarFieldMetadataItemIsUIReadOnly ||
|
||||
calendarFieldMetadataItemIsRestrictedForUpdate;
|
||||
|
||||
const dragIsDisabled =
|
||||
recordIsReadOnly || calendarFieldMetadataItemIsReadOnly;
|
||||
|
||||
return (
|
||||
<RecordCalendarCardComponentInstanceContext.Provider
|
||||
value={{ instanceId: recordId }}
|
||||
>
|
||||
<Draggable
|
||||
key={recordId}
|
||||
draggableId={recordId}
|
||||
key={draggableId}
|
||||
draggableId={draggableId}
|
||||
index={index}
|
||||
isDragDisabled={dragIsDisabled}
|
||||
>
|
||||
{(draggableProvided) => (
|
||||
<StyledDraggableContainer
|
||||
id={`record-calendar-card-${recordId}`}
|
||||
id={`record-calendar-card-${recordId}-${calendarDay}`}
|
||||
ref={draggableProvided?.innerRef}
|
||||
// oxlint-disable-next-line react/jsx-props-no-spreading
|
||||
{...draggableProvided?.dragHandleProps}
|
||||
|
||||
+14
-3
@@ -69,16 +69,27 @@ export const RecordCalendarCardHeader = ({
|
||||
padding={themeCssVariables.spacing[1]}
|
||||
>
|
||||
<StyledRecordChipContainer>
|
||||
<StopPropagationContainer>
|
||||
{isCompactModeActive ? (
|
||||
<RecordChip
|
||||
objectNameSingular={objectMetadataItem.nameSingular}
|
||||
record={recordStore}
|
||||
variant={ChipVariant.Transparent}
|
||||
isIconHidden={true}
|
||||
onClick={handleChipClick}
|
||||
forceDisableClick
|
||||
triggerEvent={'CLICK'}
|
||||
/>
|
||||
</StopPropagationContainer>
|
||||
) : (
|
||||
<StopPropagationContainer>
|
||||
<RecordChip
|
||||
objectNameSingular={objectMetadataItem.nameSingular}
|
||||
record={recordStore}
|
||||
variant={ChipVariant.Transparent}
|
||||
isIconHidden={true}
|
||||
onClick={handleChipClick}
|
||||
triggerEvent={'CLICK'}
|
||||
/>
|
||||
</StopPropagationContainer>
|
||||
)}
|
||||
</StyledRecordChipContainer>
|
||||
<StyledCheckboxContainer className="checkbox-container">
|
||||
<StopPropagationContainer>
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
|
||||
import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly';
|
||||
import { isFieldMetadataReadOnlyByPermissions } from '@/object-record/read-only/utils/internal/isFieldMetadataReadOnlyByPermissions';
|
||||
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
|
||||
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
|
||||
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const useIsRecordCalendarCardDragDisabled = (recordId: string) => {
|
||||
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
|
||||
const recordIsReadOnly = useIsRecordReadOnly({
|
||||
recordId,
|
||||
objectMetadataId: objectMetadataItem.id,
|
||||
});
|
||||
const objectPermissions = useObjectPermissionsForObject(
|
||||
objectMetadataItem.id,
|
||||
);
|
||||
const recordIndexCalendarFieldMetadataId = useAtomStateValue(
|
||||
recordIndexCalendarFieldMetadataIdState,
|
||||
);
|
||||
const recordIndexCalendarEndFieldMetadataId = useAtomStateValue(
|
||||
recordIndexCalendarEndFieldMetadataIdState,
|
||||
);
|
||||
|
||||
const calendarFieldMetadataItem = objectMetadataItem.fields.find(
|
||||
(field) => field.id === recordIndexCalendarFieldMetadataId,
|
||||
);
|
||||
const calendarEndFieldMetadataItem = objectMetadataItem.fields.find(
|
||||
(field) => field.id === recordIndexCalendarEndFieldMetadataId,
|
||||
);
|
||||
|
||||
const calendarFieldIsReadOnly =
|
||||
calendarFieldMetadataItem?.isUIEditable === false ||
|
||||
(isDefined(calendarFieldMetadataItem) &&
|
||||
isFieldMetadataReadOnlyByPermissions({
|
||||
objectPermissions,
|
||||
fieldMetadataId: calendarFieldMetadataItem.id,
|
||||
}));
|
||||
const calendarEndFieldIsReadOnly =
|
||||
calendarEndFieldMetadataItem?.isUIEditable === false ||
|
||||
(isDefined(calendarEndFieldMetadataItem) &&
|
||||
isFieldMetadataReadOnlyByPermissions({
|
||||
objectPermissions,
|
||||
fieldMetadataId: calendarEndFieldMetadataItem.id,
|
||||
}));
|
||||
|
||||
return (
|
||||
recordIsReadOnly || calendarFieldIsReadOnly || calendarEndFieldIsReadOnly
|
||||
);
|
||||
};
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
getRecordCalendarCardDraggableId,
|
||||
getRecordIdFromRecordCalendarCardDraggableId,
|
||||
} from '@/object-record/record-calendar/record-calendar-card/utils/getRecordCalendarCardDraggableId';
|
||||
|
||||
describe('getRecordCalendarCardDraggableId', () => {
|
||||
it('creates a different draggable id for each rendered day', () => {
|
||||
const firstDayDraggableId = getRecordCalendarCardDraggableId({
|
||||
calendarDay: '2026-07-08',
|
||||
recordId: 'record-id',
|
||||
});
|
||||
const secondDayDraggableId = getRecordCalendarCardDraggableId({
|
||||
calendarDay: '2026-07-09',
|
||||
recordId: 'record-id',
|
||||
});
|
||||
|
||||
expect(firstDayDraggableId).not.toBe(secondDayDraggableId);
|
||||
expect(
|
||||
getRecordIdFromRecordCalendarCardDraggableId(firstDayDraggableId),
|
||||
).toBe('record-id');
|
||||
expect(
|
||||
getRecordIdFromRecordCalendarCardDraggableId(secondDayDraggableId),
|
||||
).toBe('record-id');
|
||||
});
|
||||
|
||||
it('preserves record ids containing separator characters', () => {
|
||||
const draggableId = getRecordCalendarCardDraggableId({
|
||||
calendarDay: '2026-07-08',
|
||||
recordId: 'record:id/with special characters',
|
||||
});
|
||||
|
||||
expect(getRecordIdFromRecordCalendarCardDraggableId(draggableId)).toBe(
|
||||
'record:id/with special characters',
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts legacy draggable ids', () => {
|
||||
expect(
|
||||
getRecordIdFromRecordCalendarCardDraggableId('legacy-record-id'),
|
||||
).toBe('legacy-record-id');
|
||||
});
|
||||
});
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
const RECORD_CALENDAR_CARD_DRAGGABLE_ID_PREFIX = 'calendar-card:';
|
||||
|
||||
export const getRecordCalendarCardDraggableId = ({
|
||||
calendarDay,
|
||||
recordId,
|
||||
}: {
|
||||
calendarDay: string;
|
||||
recordId: string;
|
||||
}) =>
|
||||
`${RECORD_CALENDAR_CARD_DRAGGABLE_ID_PREFIX}${encodeURIComponent(recordId)}:${calendarDay}`;
|
||||
|
||||
export const getRecordIdFromRecordCalendarCardDraggableId = (
|
||||
draggableId: string,
|
||||
) => {
|
||||
if (!draggableId.startsWith(RECORD_CALENDAR_CARD_DRAGGABLE_ID_PREFIX)) {
|
||||
return draggableId;
|
||||
}
|
||||
|
||||
const encodedRecordIdAndCalendarDay = draggableId.slice(
|
||||
RECORD_CALENDAR_CARD_DRAGGABLE_ID_PREFIX.length,
|
||||
);
|
||||
const calendarDaySeparatorIndex =
|
||||
encodedRecordIdAndCalendarDay.lastIndexOf(':');
|
||||
|
||||
if (calendarDaySeparatorIndex <= 0) {
|
||||
return draggableId;
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(
|
||||
encodedRecordIdAndCalendarDay.slice(0, calendarDaySeparatorIndex),
|
||||
);
|
||||
} catch {
|
||||
return draggableId;
|
||||
}
|
||||
};
|
||||
+47
-7
@@ -3,6 +3,9 @@ import { hasObjectMetadataItemPositionField } from '@/object-metadata/utils/hasO
|
||||
|
||||
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
|
||||
import { recordCalendarRecordIdsComponentState } from '@/object-record/record-calendar/states/recordCalendarRecordIdsComponentState';
|
||||
import { isRecordCalendarDayInDateRange } from '@/object-record/record-calendar/utils/isRecordCalendarDayInDateRange';
|
||||
import { isRecordCalendarDayInDateTimeRange } from '@/object-record/record-calendar/utils/isRecordCalendarDayInDateTimeRange';
|
||||
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 { createAtomComponentFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilySelector';
|
||||
@@ -25,6 +28,9 @@ export const calendarDayRecordIdsComponentFamilySelector =
|
||||
const calendarFieldMetadataId = get(
|
||||
recordIndexCalendarFieldMetadataIdState,
|
||||
);
|
||||
const calendarEndFieldMetadataId = get(
|
||||
recordIndexCalendarEndFieldMetadataIdState,
|
||||
);
|
||||
|
||||
const objectMetadataItems = get(objectMetadataItemsSelector);
|
||||
const objectMetadataItem = objectMetadataItems.find(
|
||||
@@ -48,6 +54,11 @@ export const calendarDayRecordIdsComponentFamilySelector =
|
||||
return [];
|
||||
}
|
||||
|
||||
const endFieldMetadataItem = objectMetadataItem.fields.find(
|
||||
(fieldMetadataItem) =>
|
||||
fieldMetadataItem.id === calendarEndFieldMetadataId,
|
||||
);
|
||||
|
||||
const allRecordIds = get(recordCalendarRecordIdsComponentState, {
|
||||
instanceId,
|
||||
});
|
||||
@@ -61,14 +72,43 @@ export const calendarDayRecordIdsComponentFamilySelector =
|
||||
return false;
|
||||
}
|
||||
|
||||
const recordDateAsPlainDateInTimeZone =
|
||||
fieldMetadataItem.type === FieldMetadataType.DATE
|
||||
? Temporal.PlainDate.from(recordDate)
|
||||
: Temporal.Instant.from(recordDate)
|
||||
.toZonedDateTimeISO(timeZone)
|
||||
.toPlainDate();
|
||||
if (fieldMetadataItem.type === FieldMetadataType.DATE) {
|
||||
const recordStartDate = Temporal.PlainDate.from(recordDate);
|
||||
const recordEndDateValue = isDefined(endFieldMetadataItem)
|
||||
? record?.[endFieldMetadataItem.name]
|
||||
: undefined;
|
||||
|
||||
return isSamePlainDate(day, recordDateAsPlainDateInTimeZone);
|
||||
if (
|
||||
endFieldMetadataItem?.type === FieldMetadataType.DATE &&
|
||||
isNonEmptyString(recordEndDateValue)
|
||||
) {
|
||||
try {
|
||||
return isRecordCalendarDayInDateRange({
|
||||
day,
|
||||
startDate: recordStartDate,
|
||||
endDate: Temporal.PlainDate.from(recordEndDateValue),
|
||||
});
|
||||
} catch {
|
||||
return isSamePlainDate(day, recordStartDate);
|
||||
}
|
||||
}
|
||||
|
||||
return isSamePlainDate(day, recordStartDate);
|
||||
}
|
||||
|
||||
const recordEndDateValue = isDefined(endFieldMetadataItem)
|
||||
? record?.[endFieldMetadataItem.name]
|
||||
: undefined;
|
||||
|
||||
return isRecordCalendarDayInDateTimeRange({
|
||||
day,
|
||||
startDateTime: recordDate,
|
||||
endDateTime:
|
||||
endFieldMetadataItem?.type === FieldMetadataType.DATE_TIME
|
||||
? recordEndDateValue
|
||||
: undefined,
|
||||
timeZone,
|
||||
});
|
||||
});
|
||||
|
||||
if (
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { getEffectiveRecordCalendarEndFieldMetadataId } from '@/object-record/record-calendar/utils/getEffectiveRecordCalendarEndFieldMetadataId';
|
||||
|
||||
describe('getEffectiveRecordCalendarEndFieldMetadataId', () => {
|
||||
it('keeps the configured end field when the week view is enabled', () => {
|
||||
expect(
|
||||
getEffectiveRecordCalendarEndFieldMetadataId({
|
||||
calendarEndFieldMetadataId: 'end-field-id',
|
||||
isCalendarWeekViewEnabled: true,
|
||||
}),
|
||||
).toBe('end-field-id');
|
||||
});
|
||||
|
||||
it.each([null, undefined])(
|
||||
'normalizes %s to null when the week view is enabled',
|
||||
(calendarEndFieldMetadataId) => {
|
||||
expect(
|
||||
getEffectiveRecordCalendarEndFieldMetadataId({
|
||||
calendarEndFieldMetadataId,
|
||||
isCalendarWeekViewEnabled: true,
|
||||
}),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('makes a configured end field inert when the week view is disabled', () => {
|
||||
expect(
|
||||
getEffectiveRecordCalendarEndFieldMetadataId({
|
||||
calendarEndFieldMetadataId: 'end-field-id',
|
||||
isCalendarWeekViewEnabled: false,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
|
||||
import { ViewCalendarLayout } from '~/generated-metadata/graphql';
|
||||
|
||||
describe('getSupportedRecordCalendarLayout', () => {
|
||||
it.each([
|
||||
[ViewCalendarLayout.WEEK, ViewCalendarLayout.WEEK],
|
||||
[ViewCalendarLayout.MONTH, ViewCalendarLayout.MONTH],
|
||||
[ViewCalendarLayout.DAY, ViewCalendarLayout.MONTH],
|
||||
[null, ViewCalendarLayout.MONTH],
|
||||
[undefined, ViewCalendarLayout.MONTH],
|
||||
])(
|
||||
'normalizes %s to %s when the week view is enabled',
|
||||
(calendarLayout, expectedLayout) => {
|
||||
expect(
|
||||
getSupportedRecordCalendarLayout({
|
||||
calendarLayout,
|
||||
isCalendarWeekViewEnabled: true,
|
||||
}),
|
||||
).toBe(expectedLayout);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
ViewCalendarLayout.WEEK,
|
||||
ViewCalendarLayout.MONTH,
|
||||
ViewCalendarLayout.DAY,
|
||||
null,
|
||||
undefined,
|
||||
])(
|
||||
'normalizes %s to month when the week view is disabled',
|
||||
(calendarLayout) => {
|
||||
expect(
|
||||
getSupportedRecordCalendarLayout({
|
||||
calendarLayout,
|
||||
isCalendarWeekViewEnabled: false,
|
||||
}),
|
||||
).toBe(ViewCalendarLayout.MONTH);
|
||||
},
|
||||
);
|
||||
});
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { isRecordCalendarDayInDateRange } from '@/object-record/record-calendar/utils/isRecordCalendarDayInDateRange';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
describe('isRecordCalendarDayInDateRange', () => {
|
||||
const startDate = Temporal.PlainDate.from('2026-07-06');
|
||||
const endDate = Temporal.PlainDate.from('2026-07-08');
|
||||
|
||||
it.each([
|
||||
['2026-07-05', false],
|
||||
['2026-07-06', true],
|
||||
['2026-07-07', true],
|
||||
['2026-07-08', true],
|
||||
['2026-07-09', false],
|
||||
])('checks whether %s is inside the inclusive range', (day, expected) => {
|
||||
expect(
|
||||
isRecordCalendarDayInDateRange({
|
||||
day: Temporal.PlainDate.from(day),
|
||||
startDate,
|
||||
endDate,
|
||||
}),
|
||||
).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([undefined, null, Temporal.PlainDate.from('2026-07-05')])(
|
||||
'falls back to the start day for an unusable end date',
|
||||
(unusableEndDate) => {
|
||||
expect(
|
||||
isRecordCalendarDayInDateRange({
|
||||
day: startDate,
|
||||
startDate,
|
||||
endDate: unusableEndDate,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isRecordCalendarDayInDateRange({
|
||||
day: startDate.add({ days: 1 }),
|
||||
startDate,
|
||||
endDate: unusableEndDate,
|
||||
}),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { isRecordCalendarDayInDateTimeRange } from '@/object-record/record-calendar/utils/isRecordCalendarDayInDateTimeRange';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
const timeZone = 'Europe/Paris';
|
||||
|
||||
describe('isRecordCalendarDayInDateTimeRange', () => {
|
||||
it.each([
|
||||
['2026-07-07', false],
|
||||
['2026-07-08', true],
|
||||
['2026-07-09', true],
|
||||
['2026-07-10', true],
|
||||
['2026-07-11', false],
|
||||
])('matches the overlapping days of a multi-day event: %s', (day, result) => {
|
||||
expect(
|
||||
isRecordCalendarDayInDateTimeRange({
|
||||
day: Temporal.PlainDate.from(day),
|
||||
startDateTime: '2026-07-08T15:59:00Z',
|
||||
endDateTime: '2026-07-10T18:59:00Z',
|
||||
timeZone,
|
||||
}),
|
||||
).toBe(result);
|
||||
});
|
||||
|
||||
it('does not include the next day when an event ends exactly at midnight', () => {
|
||||
expect(
|
||||
isRecordCalendarDayInDateTimeRange({
|
||||
day: Temporal.PlainDate.from('2026-07-10'),
|
||||
startDateTime: '2026-07-08T15:59:00Z',
|
||||
endDateTime: '2026-07-09T22:00:00Z',
|
||||
timeZone,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the one-hour fallback when the end is unusable', () => {
|
||||
expect(
|
||||
isRecordCalendarDayInDateTimeRange({
|
||||
day: Temporal.PlainDate.from('2026-07-09'),
|
||||
startDateTime: '2026-07-08T21:30:00Z',
|
||||
endDateTime: 'not-a-date',
|
||||
timeZone,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the start is unusable', () => {
|
||||
expect(
|
||||
isRecordCalendarDayInDateTimeRange({
|
||||
day: Temporal.PlainDate.from('2026-07-08'),
|
||||
startDateTime: 'not-a-date',
|
||||
timeZone,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
type GetEffectiveRecordCalendarEndFieldMetadataIdArgs = {
|
||||
calendarEndFieldMetadataId: string | null | undefined;
|
||||
isCalendarWeekViewEnabled: boolean;
|
||||
};
|
||||
|
||||
export const getEffectiveRecordCalendarEndFieldMetadataId = ({
|
||||
calendarEndFieldMetadataId,
|
||||
isCalendarWeekViewEnabled,
|
||||
}: GetEffectiveRecordCalendarEndFieldMetadataIdArgs) =>
|
||||
isCalendarWeekViewEnabled ? (calendarEndFieldMetadataId ?? null) : null;
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
type GetRecordCalendarDateTimeRangeArgs = {
|
||||
endDateTime?: unknown;
|
||||
startDateTime: unknown;
|
||||
timeZone: string;
|
||||
};
|
||||
|
||||
export type RecordCalendarDateTimeRange = {
|
||||
end: Temporal.ZonedDateTime;
|
||||
isEndDateTimeFallback: boolean;
|
||||
start: Temporal.ZonedDateTime;
|
||||
};
|
||||
|
||||
const getZonedDateTimeFromValue = (value: unknown, timeZone: string) => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Temporal.Instant.from(value).toZonedDateTimeISO(timeZone);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getRecordCalendarDateTimeRange = ({
|
||||
endDateTime,
|
||||
startDateTime,
|
||||
timeZone,
|
||||
}: GetRecordCalendarDateTimeRangeArgs): RecordCalendarDateTimeRange | null => {
|
||||
const start = getZonedDateTimeFromValue(startDateTime, timeZone);
|
||||
|
||||
if (start === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configuredEnd = getZonedDateTimeFromValue(endDateTime, timeZone);
|
||||
const isEndDateTimeFallback =
|
||||
configuredEnd === null ||
|
||||
Temporal.Instant.compare(configuredEnd.toInstant(), start.toInstant()) <= 0;
|
||||
const end = isEndDateTimeFallback ? start.add({ hours: 1 }) : configuredEnd;
|
||||
|
||||
return { end, isEndDateTimeFallback, start };
|
||||
};
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { ViewCalendarLayout } from '~/generated-metadata/graphql';
|
||||
|
||||
type GetSupportedRecordCalendarLayoutArgs = {
|
||||
calendarLayout: ViewCalendarLayout | null | undefined;
|
||||
isCalendarWeekViewEnabled: boolean;
|
||||
};
|
||||
|
||||
export const getSupportedRecordCalendarLayout = ({
|
||||
calendarLayout,
|
||||
isCalendarWeekViewEnabled,
|
||||
}: GetSupportedRecordCalendarLayoutArgs) =>
|
||||
isCalendarWeekViewEnabled && calendarLayout === ViewCalendarLayout.WEEK
|
||||
? ViewCalendarLayout.WEEK
|
||||
: ViewCalendarLayout.MONTH;
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
type IsRecordCalendarDayInDateRangeArgs = {
|
||||
day: Temporal.PlainDate;
|
||||
endDate?: Temporal.PlainDate | null;
|
||||
startDate: Temporal.PlainDate;
|
||||
};
|
||||
|
||||
export const isRecordCalendarDayInDateRange = ({
|
||||
day,
|
||||
endDate,
|
||||
startDate,
|
||||
}: IsRecordCalendarDayInDateRangeArgs) => {
|
||||
const validEndDate =
|
||||
endDate !== null &&
|
||||
endDate !== undefined &&
|
||||
Temporal.PlainDate.compare(endDate, startDate) >= 0
|
||||
? endDate
|
||||
: startDate;
|
||||
|
||||
return (
|
||||
Temporal.PlainDate.compare(day, startDate) >= 0 &&
|
||||
Temporal.PlainDate.compare(day, validEndDate) <= 0
|
||||
);
|
||||
};
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { getRecordCalendarDateTimeRange } from '@/object-record/record-calendar/utils/getRecordCalendarDateTimeRange';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
type IsRecordCalendarDayInDateTimeRangeArgs = {
|
||||
day: Temporal.PlainDate;
|
||||
endDateTime?: unknown;
|
||||
startDateTime: unknown;
|
||||
timeZone: string;
|
||||
};
|
||||
|
||||
export const isRecordCalendarDayInDateTimeRange = ({
|
||||
day,
|
||||
endDateTime,
|
||||
startDateTime,
|
||||
timeZone,
|
||||
}: IsRecordCalendarDayInDateTimeRangeArgs) => {
|
||||
const range = getRecordCalendarDateTimeRange({
|
||||
endDateTime,
|
||||
startDateTime,
|
||||
timeZone,
|
||||
});
|
||||
|
||||
if (range === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dayStart = day.toZonedDateTime({ timeZone }).toInstant();
|
||||
const nextDayStart = day
|
||||
.add({ days: 1 })
|
||||
.toZonedDateTime({ timeZone })
|
||||
.toInstant();
|
||||
|
||||
return (
|
||||
Temporal.Instant.compare(range.start.toInstant(), nextDayStart) < 0 &&
|
||||
Temporal.Instant.compare(range.end.toInstant(), dayStart) > 0
|
||||
);
|
||||
};
|
||||
+704
@@ -0,0 +1,704 @@
|
||||
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 { 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const RecordCalendarWeek = () => {
|
||||
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
|
||||
const { timeFormat, timeZone } = useDateTimeFormat();
|
||||
const recordCalendarId = useAvailableComponentInstanceIdOrThrow(
|
||||
RecordCalendarComponentInstanceContext,
|
||||
);
|
||||
const recordCalendarSelectedDate = useAtomComponentStateValue(
|
||||
recordCalendarSelectedDateComponentState,
|
||||
recordCalendarId,
|
||||
);
|
||||
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 { 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>
|
||||
);
|
||||
};
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import { useProcessRecordCalendarWeekEventDrop } from '@/object-record/record-calendar/week/hooks/useProcessRecordCalendarWeekEventDrop';
|
||||
import { type RecordCalendarWeekDndData } from '@/object-record/record-calendar/week/types/RecordCalendarWeekDndData';
|
||||
import { resolveRecordCalendarWeekEventDrop } from '@/object-record/record-calendar/week/utils/resolveRecordCalendarWeekEventDrop';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors';
|
||||
import { DragDropProvider } from '@dnd-kit/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import {
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { Temporal } from 'temporal-polyfill';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { logError } from '~/utils/logError';
|
||||
|
||||
type DragStartPayload = Parameters<
|
||||
NonNullable<
|
||||
ComponentProps<
|
||||
typeof DragDropProvider<RecordCalendarWeekDndData>
|
||||
>['onDragStart']
|
||||
>
|
||||
>[0];
|
||||
type DragEndPayload = Parameters<
|
||||
NonNullable<
|
||||
ComponentProps<
|
||||
typeof DragDropProvider<RecordCalendarWeekDndData>
|
||||
>['onDragEnd']
|
||||
>
|
||||
>[0];
|
||||
|
||||
type RecordCalendarWeekDragDropContextProps = {
|
||||
children: ReactNode;
|
||||
gridRef: RefObject<HTMLDivElement | null>;
|
||||
weekDays: Temporal.PlainDate[];
|
||||
};
|
||||
|
||||
export const RecordCalendarWeekDragDropContext = ({
|
||||
children,
|
||||
gridRef,
|
||||
weekDays,
|
||||
}: RecordCalendarWeekDragDropContextProps) => {
|
||||
const [grabOffsetY, setGrabOffsetY] = useState(0);
|
||||
const { processRecordCalendarWeekEventDrop } =
|
||||
useProcessRecordCalendarWeekEventDrop();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
const handleDragStart = ({ operation }: DragStartPayload) => {
|
||||
const sourceElement = operation.source?.element;
|
||||
|
||||
if (!isDefined(sourceElement)) {
|
||||
setGrabOffsetY(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setGrabOffsetY(
|
||||
operation.position.current.y - sourceElement.getBoundingClientRect().top,
|
||||
);
|
||||
};
|
||||
|
||||
const handleDragEnd = ({ canceled, operation }: DragEndPayload) => {
|
||||
if (
|
||||
canceled ||
|
||||
!isDefined(operation.source) ||
|
||||
!isDefined(gridRef.current)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceData = operation.source.data;
|
||||
|
||||
if (sourceData.kind !== 'record-calendar-week-event') {
|
||||
return;
|
||||
}
|
||||
|
||||
const gridRect = gridRef.current.getBoundingClientRect();
|
||||
const resolvedDrop = resolveRecordCalendarWeekEventDrop({
|
||||
dayCount: weekDays.length,
|
||||
grabOffsetY,
|
||||
gridRect,
|
||||
pointerX: operation.position.current.x,
|
||||
pointerY: operation.position.current.y,
|
||||
});
|
||||
|
||||
if (!isDefined(resolvedDrop)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const destinationDay = weekDays[resolvedDrop.dayIndex];
|
||||
|
||||
if (!isDefined(destinationDay)) {
|
||||
return;
|
||||
}
|
||||
|
||||
void processRecordCalendarWeekEventDrop({
|
||||
destinationDay,
|
||||
destinationMinutes: resolvedDrop.destinationMinutes,
|
||||
recordId: sourceData.recordId,
|
||||
}).catch((error) => {
|
||||
logError(error);
|
||||
enqueueErrorSnackBar({ message: t`Failed to move record` });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropProvider<RecordCalendarWeekDndData>
|
||||
sensors={DND_KIT_SENSORS}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{children}
|
||||
</DragDropProvider>
|
||||
);
|
||||
};
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
import { RecordChip } from '@/object-record/components/RecordChip';
|
||||
import { StopPropagationContainer } from '@/object-record/record-board/record-board-card/components/StopPropagationContainer';
|
||||
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
|
||||
import { RECORD_CALENDAR_CARD_CLICK_OUTSIDE_ID } from '@/object-record/record-calendar/record-calendar-card/constants/RecordCalendarCardClickOutsideId';
|
||||
import { useIsRecordCalendarCardDragDisabled } from '@/object-record/record-calendar/record-calendar-card/hooks/useIsRecordCalendarCardDragDisabled';
|
||||
import { isRecordCalendarCardSelectedComponentFamilyState } from '@/object-record/record-calendar/record-calendar-card/states/isRecordCalendarCardSelectedComponentFamilyState';
|
||||
import { getRecordCalendarCardDraggableId } from '@/object-record/record-calendar/record-calendar-card/utils/getRecordCalendarCardDraggableId';
|
||||
import { RECORD_CALENDAR_WEEK_DIMENSIONS } from '@/object-record/record-calendar/week/constants/RecordCalendarWeekDimensions';
|
||||
import { type RecordCalendarWeekDndData } from '@/object-record/record-calendar/week/types/RecordCalendarWeekDndData';
|
||||
import { formatRecordCalendarWeekEventTimes } from '@/object-record/record-calendar/week/utils/formatRecordCalendarWeekEventTimes';
|
||||
import { getRecordCalendarWeekEventHorizontalPosition } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekEventHorizontalPosition';
|
||||
import { getRecordCalendarWeekTimedEventHeight } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekTimedEventMetrics';
|
||||
import { RecordCard } from '@/object-record/record-card/components/RecordCard';
|
||||
import { useOpenRecordFromIndexView } from '@/object-record/record-index/hooks/useOpenRecordFromIndexView';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { useAtomComponentFamilyState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyState';
|
||||
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
|
||||
import { useDraggable } from '@dnd-kit/react';
|
||||
import { styled } from '@linaria/react';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { ChipVariant } from 'twenty-ui/data-display';
|
||||
import { Checkbox, CheckboxVariant } from 'twenty-ui/input';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
const RECORD_CALENDAR_WEEK_EVENT_TIME_ROW_HEIGHT = 14;
|
||||
const RECORD_CALENDAR_WEEK_EVENT_EXPANDED_MIN_HEIGHT =
|
||||
RECORD_CALENDAR_WEEK_DIMENSIONS.minimumEventSlotHeight +
|
||||
RECORD_CALENDAR_WEEK_EVENT_TIME_ROW_HEIGHT;
|
||||
|
||||
const StyledEventPositioner = styled.div<{
|
||||
columnCount: number;
|
||||
columnIndex: number;
|
||||
heightInPixels: number;
|
||||
isAllDay: boolean;
|
||||
topInPixels: number;
|
||||
}>`
|
||||
box-sizing: border-box;
|
||||
height: ${({ heightInPixels, isAllDay }) =>
|
||||
isAllDay ? '22px' : `${heightInPixels}px`};
|
||||
left: ${({ columnCount, columnIndex, isAllDay }) =>
|
||||
isAllDay
|
||||
? 'auto'
|
||||
: getRecordCalendarWeekEventHorizontalPosition({
|
||||
columnCount,
|
||||
columnIndex,
|
||||
}).left};
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
position: ${({ isAllDay }) => (isAllDay ? 'relative' : 'absolute')};
|
||||
right: auto;
|
||||
top: ${({ isAllDay, topInPixels }) =>
|
||||
isAllDay
|
||||
? 'auto'
|
||||
: `${topInPixels + RECORD_CALENDAR_WEEK_DIMENSIONS.eventVerticalGap / 2}px`};
|
||||
width: ${({ columnCount, columnIndex, isAllDay }) =>
|
||||
isAllDay
|
||||
? '100%'
|
||||
: getRecordCalendarWeekEventHorizontalPosition({
|
||||
columnCount,
|
||||
columnIndex,
|
||||
}).width};
|
||||
z-index: ${({ columnCount, columnIndex, isAllDay }) =>
|
||||
isAllDay
|
||||
? 1
|
||||
: getRecordCalendarWeekEventHorizontalPosition({
|
||||
columnCount,
|
||||
columnIndex,
|
||||
}).stackingOrder};
|
||||
|
||||
&:focus-within,
|
||||
&:hover {
|
||||
z-index: ${({ columnCount, columnIndex, isAllDay }) =>
|
||||
isAllDay
|
||||
? 1
|
||||
: getRecordCalendarWeekEventHorizontalPosition({
|
||||
columnCount,
|
||||
columnIndex,
|
||||
}).hoverStackingOrder};
|
||||
}
|
||||
|
||||
> div {
|
||||
height: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledEventContent = styled.div<{ isAllDay: boolean }>`
|
||||
align-items: ${({ isAllDay }) => (isAllDay ? 'center' : 'flex-start')};
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: ${({ isAllDay }) => (isAllDay ? 'row' : 'column')};
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: ${({ isAllDay }) =>
|
||||
isAllDay
|
||||
? `0 ${themeCssVariables.spacing['0.5']}`
|
||||
: `${themeCssVariables.spacing['0.5']} ${themeCssVariables.spacing[1]}`};
|
||||
position: relative;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledEventHeader = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 20px;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledEventLabel = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
height: 20px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledRecordChipContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
[data-testid='chip'],
|
||||
[data-testid='chip'] > * {
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
}
|
||||
|
||||
[data-testid='chip'] {
|
||||
min-width: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCompactEventStartTime = styled.span`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
flex-shrink: 0;
|
||||
font-size: ${themeCssVariables.font.size.xxs};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
line-height: 12px;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const StyledCheckboxContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
height: 20px;
|
||||
margin-left: auto;
|
||||
`;
|
||||
|
||||
const StyledEventTimeRow = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: ${RECORD_CALENDAR_WEEK_EVENT_TIME_ROW_HEIGHT}px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledEventTime = styled.span`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-size: ${themeCssVariables.font.size.xxs};
|
||||
line-height: 12px;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
type RecordCalendarWeekEventProps = {
|
||||
calendarDay: Temporal.PlainDate;
|
||||
calendarEndFieldName?: string;
|
||||
calendarFieldName: string;
|
||||
calendarFieldType: FieldMetadataType;
|
||||
columnCount?: number;
|
||||
columnIndex?: number;
|
||||
endInPixels?: number;
|
||||
isAllDay: boolean;
|
||||
recordId: string;
|
||||
startInPixels?: number;
|
||||
timeFormat: string;
|
||||
timeZone: string;
|
||||
};
|
||||
|
||||
export const RecordCalendarWeekEvent = ({
|
||||
calendarDay,
|
||||
calendarEndFieldName,
|
||||
calendarFieldName,
|
||||
calendarFieldType,
|
||||
columnCount = 1,
|
||||
columnIndex = 0,
|
||||
endInPixels = 0,
|
||||
isAllDay,
|
||||
recordId,
|
||||
startInPixels = 0,
|
||||
timeFormat,
|
||||
timeZone,
|
||||
}: RecordCalendarWeekEventProps) => {
|
||||
const { objectNameSingular } = useRecordCalendarContextOrThrow();
|
||||
const { openRecordFromIndexView } = useOpenRecordFromIndexView();
|
||||
const dragIsDisabled = useIsRecordCalendarCardDragDisabled(recordId);
|
||||
const draggableId = getRecordCalendarCardDraggableId({
|
||||
calendarDay: calendarDay.toString(),
|
||||
recordId,
|
||||
});
|
||||
const { isDragSource, ref: draggableRef } =
|
||||
useDraggable<RecordCalendarWeekDndData>({
|
||||
id: draggableId,
|
||||
data: {
|
||||
kind: 'record-calendar-week-event',
|
||||
recordId,
|
||||
},
|
||||
disabled: isAllDay || dragIsDisabled,
|
||||
feedback: 'clone',
|
||||
});
|
||||
const recordStore = useAtomFamilyStateValue(recordStoreFamilyState, recordId);
|
||||
const recordDate = recordStore?.[calendarFieldName];
|
||||
const recordEndDate = isDefined(calendarEndFieldName)
|
||||
? recordStore?.[calendarEndFieldName]
|
||||
: undefined;
|
||||
|
||||
const [isRecordCalendarCardSelected, setIsRecordCalendarCardSelected] =
|
||||
useAtomComponentFamilyState(
|
||||
isRecordCalendarCardSelectedComponentFamilyState,
|
||||
recordId,
|
||||
);
|
||||
|
||||
const isDateOnly = calendarFieldType === FieldMetadataType.DATE;
|
||||
|
||||
if (
|
||||
!isDefined(recordStore) ||
|
||||
typeof recordDate !== 'string' ||
|
||||
isAllDay !== isDateOnly
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const eventTimes = !isDateOnly
|
||||
? formatRecordCalendarWeekEventTimes({
|
||||
startDateTime: recordDate,
|
||||
endDateTime: recordEndDate,
|
||||
timeFormat,
|
||||
timeZone,
|
||||
})
|
||||
: null;
|
||||
const heightInPixels = getRecordCalendarWeekTimedEventHeight({
|
||||
endInPixels,
|
||||
startInPixels,
|
||||
});
|
||||
const isCompactTimedEvent =
|
||||
!isAllDay &&
|
||||
heightInPixels < RECORD_CALENDAR_WEEK_EVENT_EXPANDED_MIN_HEIGHT;
|
||||
const expandedEventTime = eventTimes?.timeRange;
|
||||
|
||||
return (
|
||||
<StyledEventPositioner
|
||||
ref={draggableRef}
|
||||
columnCount={columnCount}
|
||||
columnIndex={columnIndex}
|
||||
heightInPixels={heightInPixels}
|
||||
isAllDay={isAllDay}
|
||||
topInPixels={startInPixels}
|
||||
data-selectable-id={recordId}
|
||||
>
|
||||
<RecordCard
|
||||
data-click-outside-id={RECORD_CALENDAR_CARD_CLICK_OUTSIDE_ID}
|
||||
data-selected={isRecordCalendarCardSelected}
|
||||
onClick={() => {
|
||||
if (!isDragSource) {
|
||||
openRecordFromIndexView({ recordId });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StyledEventContent isAllDay={isAllDay}>
|
||||
<StyledEventHeader>
|
||||
<StyledEventLabel>
|
||||
<StyledRecordChipContainer>
|
||||
<RecordChip
|
||||
objectNameSingular={objectNameSingular}
|
||||
record={recordStore}
|
||||
variant={ChipVariant.Transparent}
|
||||
isIconHidden
|
||||
forceDisableClick
|
||||
triggerEvent="CLICK"
|
||||
/>
|
||||
</StyledRecordChipContainer>
|
||||
{isCompactTimedEvent && isDefined(eventTimes) && (
|
||||
<StyledCompactEventStartTime>
|
||||
{`, ${eventTimes.startTime}`}
|
||||
</StyledCompactEventStartTime>
|
||||
)}
|
||||
</StyledEventLabel>
|
||||
<StyledCheckboxContainer className="checkbox-container">
|
||||
<StopPropagationContainer>
|
||||
<Checkbox
|
||||
hoverable
|
||||
checked={isRecordCalendarCardSelected}
|
||||
onChange={(event) => {
|
||||
setIsRecordCalendarCardSelected(event.target.checked);
|
||||
}}
|
||||
variant={CheckboxVariant.Secondary}
|
||||
/>
|
||||
</StopPropagationContainer>
|
||||
</StyledCheckboxContainer>
|
||||
</StyledEventHeader>
|
||||
{!isAllDay &&
|
||||
!isCompactTimedEvent &&
|
||||
isDefined(expandedEventTime) && (
|
||||
<StyledEventTimeRow>
|
||||
<StyledEventTime>{expandedEventTime}</StyledEventTime>
|
||||
</StyledEventTimeRow>
|
||||
)}
|
||||
</StyledEventContent>
|
||||
</RecordCard>
|
||||
</StyledEventPositioner>
|
||||
);
|
||||
};
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
function createRecordCalendarWeekDimensions({
|
||||
hourHeight,
|
||||
hoursInDay,
|
||||
snapIntervalInMinutes,
|
||||
}: {
|
||||
hourHeight: number;
|
||||
hoursInDay: number;
|
||||
snapIntervalInMinutes: number;
|
||||
}) {
|
||||
return {
|
||||
eventVerticalGap: 8,
|
||||
gridHeight: hoursInDay * hourHeight,
|
||||
hourHeight,
|
||||
hoursInDay,
|
||||
minimumEventSlotHeight: 24,
|
||||
slotHeight: (hourHeight * snapIntervalInMinutes) / 60,
|
||||
snapIntervalInMinutes,
|
||||
timeGutterWidth: 56,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export const RECORD_CALENDAR_WEEK_DIMENSIONS =
|
||||
createRecordCalendarWeekDimensions({
|
||||
hourHeight: 48,
|
||||
hoursInDay: 24,
|
||||
snapIntervalInMinutes: 30,
|
||||
});
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
||||
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
|
||||
import { getRecordCalendarWeekEventDropDateTime } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekEventDropDateTime';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
|
||||
import { useStore } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
import type { Temporal } from 'temporal-polyfill';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
|
||||
type ProcessRecordCalendarWeekEventDropArgs = {
|
||||
destinationDay: Temporal.PlainDate;
|
||||
destinationMinutes: number;
|
||||
recordId: string;
|
||||
};
|
||||
|
||||
export const useProcessRecordCalendarWeekEventDrop = () => {
|
||||
const store = useStore();
|
||||
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
|
||||
const { currentView } = useGetCurrentViewOnly();
|
||||
const { updateOneRecord } = useUpdateOneRecord();
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
const processRecordCalendarWeekEventDrop = useCallback(
|
||||
async ({
|
||||
destinationDay,
|
||||
destinationMinutes,
|
||||
recordId,
|
||||
}: ProcessRecordCalendarWeekEventDropArgs) => {
|
||||
const calendarFieldMetadataItem = objectMetadataItem.fields.find(
|
||||
(field) => field.id === currentView?.calendarFieldMetadataId,
|
||||
);
|
||||
|
||||
if (calendarFieldMetadataItem?.type !== FieldMetadataType.DATE_TIME) {
|
||||
return;
|
||||
}
|
||||
|
||||
const calendarEndFieldMetadataItem = objectMetadataItem.fields.find(
|
||||
(field) => field.id === currentView?.calendarEndFieldMetadataId,
|
||||
);
|
||||
const record = store.get(recordStoreFamilyState.atomFamily(recordId));
|
||||
|
||||
if (!isDefined(record)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shiftedDateTime = getRecordCalendarWeekEventDropDateTime({
|
||||
destinationDay,
|
||||
destinationMinutes,
|
||||
startDateTime: record[calendarFieldMetadataItem.name],
|
||||
endDateTime:
|
||||
calendarEndFieldMetadataItem?.type === FieldMetadataType.DATE_TIME
|
||||
? record[calendarEndFieldMetadataItem.name]
|
||||
: undefined,
|
||||
timeZone: userTimezone,
|
||||
});
|
||||
|
||||
if (shiftedDateTime === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await updateOneRecord({
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
idToUpdate: recordId,
|
||||
updateOneRecordInput: {
|
||||
[calendarFieldMetadataItem.name]: shiftedDateTime.startDateTime,
|
||||
...(isDefined(calendarEndFieldMetadataItem) &&
|
||||
isDefined(shiftedDateTime.endDateTime) && {
|
||||
[calendarEndFieldMetadataItem.name]: shiftedDateTime.endDateTime,
|
||||
}),
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
currentView?.calendarEndFieldMetadataId,
|
||||
currentView?.calendarFieldMetadataId,
|
||||
objectMetadataItem.fields,
|
||||
objectMetadataItem.nameSingular,
|
||||
store,
|
||||
updateOneRecord,
|
||||
userTimezone,
|
||||
],
|
||||
);
|
||||
|
||||
return { processRecordCalendarWeekEventDrop };
|
||||
};
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
|
||||
import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { format } from 'date-fns';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import { CalendarStartDay } from 'twenty-shared/constants';
|
||||
import { turnPlainDateToShiftedDateInSystemTimeZone } from 'twenty-shared/utils';
|
||||
import { dateLocaleState } from '~/localization/states/dateLocaleState';
|
||||
|
||||
export const useRecordCalendarWeekDaysRange = (
|
||||
selectedDate: Temporal.PlainDate,
|
||||
) => {
|
||||
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
|
||||
const dateLocale = useAtomStateValue(dateLocaleState);
|
||||
|
||||
const calendarStartDay =
|
||||
currentWorkspaceMember?.calendarStartDay ?? CalendarStartDay.SYSTEM;
|
||||
|
||||
const weekStartsOnDayIndex = (
|
||||
calendarStartDay === CalendarStartDay.SYSTEM
|
||||
? CalendarStartDay[detectCalendarStartDay()]
|
||||
: calendarStartDay
|
||||
) as 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
const selectedDayIndex = selectedDate.dayOfWeek % 7;
|
||||
const daysSinceStartOfWeek =
|
||||
(selectedDayIndex - weekStartsOnDayIndex + 7) % 7;
|
||||
const firstDayOfWeek = selectedDate.subtract({
|
||||
days: daysSinceStartOfWeek,
|
||||
});
|
||||
|
||||
const weekDays = Array.from({ length: 7 }, (_, index) => {
|
||||
const date = firstDayOfWeek.add({ days: index });
|
||||
|
||||
return {
|
||||
date,
|
||||
label: format(turnPlainDateToShiftedDateInSystemTimeZone(date), 'EEE', {
|
||||
locale: dateLocale.localeCatalog,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
firstDayOfWeek,
|
||||
lastDayOfWeek: firstDayOfWeek.add({ days: 6 }),
|
||||
weekDays,
|
||||
};
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export type RecordCalendarWeekDndData = {
|
||||
kind: 'record-calendar-week-event';
|
||||
recordId: string;
|
||||
};
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
import { computeRecordCalendarWeekEventLayouts } from '@/object-record/record-calendar/week/utils/computeRecordCalendarWeekEventLayouts';
|
||||
|
||||
describe('computeRecordCalendarWeekEventLayouts', () => {
|
||||
it('gives non-overlapping events the full column width', () => {
|
||||
const layouts = computeRecordCalendarWeekEventLayouts([
|
||||
{ recordId: 'first', startInPixels: 0, endInPixels: 44 },
|
||||
{ recordId: 'second', startInPixels: 44, endInPixels: 88 },
|
||||
]);
|
||||
|
||||
expect(layouts).toEqual([
|
||||
{
|
||||
recordId: 'first',
|
||||
startInPixels: 0,
|
||||
endInPixels: 44,
|
||||
columnIndex: 0,
|
||||
columnCount: 1,
|
||||
},
|
||||
{
|
||||
recordId: 'second',
|
||||
startInPixels: 44,
|
||||
endInPixels: 88,
|
||||
columnIndex: 0,
|
||||
columnCount: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('places overlapping events in adjacent columns', () => {
|
||||
const layouts = computeRecordCalendarWeekEventLayouts([
|
||||
{ recordId: 'first', startInPixels: 0, endInPixels: 44 },
|
||||
{ recordId: 'second', startInPixels: 10, endInPixels: 54 },
|
||||
{ recordId: 'third', startInPixels: 20, endInPixels: 64 },
|
||||
]);
|
||||
|
||||
expect(
|
||||
layouts.map(({ recordId, columnIndex, columnCount }) => ({
|
||||
recordId,
|
||||
columnIndex,
|
||||
columnCount,
|
||||
})),
|
||||
).toEqual([
|
||||
{ recordId: 'first', columnIndex: 0, columnCount: 3 },
|
||||
{ recordId: 'second', columnIndex: 1, columnCount: 3 },
|
||||
{ recordId: 'third', columnIndex: 2, columnCount: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps every event in a larger overlapping group', () => {
|
||||
const layouts = computeRecordCalendarWeekEventLayouts([
|
||||
{ recordId: 'first', startInPixels: 0, endInPixels: 60 },
|
||||
{ recordId: 'second', startInPixels: 0, endInPixels: 60 },
|
||||
{ recordId: 'third', startInPixels: 0, endInPixels: 60 },
|
||||
{ recordId: 'fourth', startInPixels: 15, endInPixels: 75 },
|
||||
{ recordId: 'fifth', startInPixels: 30, endInPixels: 90 },
|
||||
]);
|
||||
|
||||
expect(
|
||||
layouts.map(({ recordId, columnIndex, columnCount }) => ({
|
||||
recordId,
|
||||
columnIndex,
|
||||
columnCount,
|
||||
})),
|
||||
).toEqual([
|
||||
{ recordId: 'first', columnIndex: 0, columnCount: 5 },
|
||||
{ recordId: 'second', columnIndex: 1, columnCount: 5 },
|
||||
{ recordId: 'third', columnIndex: 2, columnCount: 5 },
|
||||
{ recordId: 'fourth', columnIndex: 3, columnCount: 5 },
|
||||
{ recordId: 'fifth', columnIndex: 4, columnCount: 5 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reuses a free column within an overlapping group', () => {
|
||||
const layouts = computeRecordCalendarWeekEventLayouts([
|
||||
{ recordId: 'first', startInPixels: 0, endInPixels: 44 },
|
||||
{ recordId: 'second', startInPixels: 10, endInPixels: 54 },
|
||||
{ recordId: 'third', startInPixels: 44, endInPixels: 88 },
|
||||
]);
|
||||
|
||||
expect(
|
||||
layouts.map(({ recordId, columnIndex, columnCount }) => ({
|
||||
recordId,
|
||||
columnIndex,
|
||||
columnCount,
|
||||
})),
|
||||
).toEqual([
|
||||
{ recordId: 'first', columnIndex: 0, columnCount: 2 },
|
||||
{ recordId: 'second', columnIndex: 1, columnCount: 2 },
|
||||
{ recordId: 'third', columnIndex: 0, columnCount: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a long event in the first column while shorter events reuse the overlay column', () => {
|
||||
const layouts = computeRecordCalendarWeekEventLayouts([
|
||||
{ recordId: 'long', startInPixels: 0, endInPixels: 180 },
|
||||
{ recordId: 'first-short', startInPixels: 30, endInPixels: 60 },
|
||||
{ recordId: 'second-short', startInPixels: 60, endInPixels: 90 },
|
||||
]);
|
||||
|
||||
expect(
|
||||
layouts.map(({ recordId, columnIndex, columnCount }) => ({
|
||||
recordId,
|
||||
columnIndex,
|
||||
columnCount,
|
||||
})),
|
||||
).toEqual([
|
||||
{ recordId: 'long', columnIndex: 0, columnCount: 2 },
|
||||
{ recordId: 'first-short', columnIndex: 1, columnCount: 2 },
|
||||
{ recordId: 'second-short', columnIndex: 1, columnCount: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('places the longer event behind shorter events with the same start', () => {
|
||||
const layouts = computeRecordCalendarWeekEventLayouts([
|
||||
{ recordId: 'short', startInPixels: 0, endInPixels: 60 },
|
||||
{ recordId: 'long', startInPixels: 0, endInPixels: 180 },
|
||||
]);
|
||||
|
||||
expect(
|
||||
layouts.map(({ recordId, columnIndex, columnCount }) => ({
|
||||
recordId,
|
||||
columnIndex,
|
||||
columnCount,
|
||||
})),
|
||||
).toEqual([
|
||||
{ recordId: 'long', columnIndex: 0, columnCount: 2 },
|
||||
{ recordId: 'short', columnIndex: 1, columnCount: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts events before assigning columns', () => {
|
||||
const layouts = computeRecordCalendarWeekEventLayouts([
|
||||
{ recordId: 'second', startInPixels: 10, endInPixels: 54 },
|
||||
{ recordId: 'first', startInPixels: 0, endInPixels: 44 },
|
||||
]);
|
||||
|
||||
expect(layouts.map(({ recordId }) => recordId)).toEqual([
|
||||
'first',
|
||||
'second',
|
||||
]);
|
||||
});
|
||||
});
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { formatRecordCalendarWeekEventTimes } from '@/object-record/record-calendar/week/utils/formatRecordCalendarWeekEventTimes';
|
||||
|
||||
const timeZone = 'Europe/Paris';
|
||||
const timeFormat = 'HH:mm';
|
||||
|
||||
describe('formatRecordCalendarWeekEventTimes', () => {
|
||||
it('formats the start time and configured time range', () => {
|
||||
expect(
|
||||
formatRecordCalendarWeekEventTimes({
|
||||
startDateTime: '2026-07-08T15:59:00Z',
|
||||
endDateTime: '2026-07-10T18:59:00Z',
|
||||
timeFormat,
|
||||
timeZone,
|
||||
}),
|
||||
).toEqual({
|
||||
startTime: '17:59',
|
||||
timeRange: '17:59 - 20:59',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing', undefined],
|
||||
['null', null],
|
||||
['invalid', 'not-a-date'],
|
||||
['equal to the start', '2026-07-08T15:59:00Z'],
|
||||
['before the start', '2026-07-08T14:59:00Z'],
|
||||
])('uses only the start time when the end is %s', (_label, endDateTime) => {
|
||||
expect(
|
||||
formatRecordCalendarWeekEventTimes({
|
||||
startDateTime: '2026-07-08T15:59:00Z',
|
||||
endDateTime,
|
||||
timeFormat,
|
||||
timeZone,
|
||||
}),
|
||||
).toEqual({
|
||||
startTime: '17:59',
|
||||
timeRange: '17:59',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when the start is unusable', () => {
|
||||
expect(
|
||||
formatRecordCalendarWeekEventTimes({
|
||||
startDateTime: 'not-a-date',
|
||||
timeFormat,
|
||||
timeZone,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { formatRecordCalendarWeekRange } from '@/object-record/record-calendar/week/utils/formatRecordCalendarWeekRange';
|
||||
import { enUS } from 'date-fns/locale';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
describe('formatRecordCalendarWeekRange', () => {
|
||||
it('formats a week within one month', () => {
|
||||
expect(
|
||||
formatRecordCalendarWeekRange({
|
||||
firstDayOfWeek: Temporal.PlainDate.from('2026-07-06'),
|
||||
lastDayOfWeek: Temporal.PlainDate.from('2026-07-12'),
|
||||
locale: enUS,
|
||||
}),
|
||||
).toBe('Jul 6 – 12, 2026');
|
||||
});
|
||||
|
||||
it('formats a week spanning two months', () => {
|
||||
expect(
|
||||
formatRecordCalendarWeekRange({
|
||||
firstDayOfWeek: Temporal.PlainDate.from('2026-06-29'),
|
||||
lastDayOfWeek: Temporal.PlainDate.from('2026-07-05'),
|
||||
locale: enUS,
|
||||
}),
|
||||
).toBe('Jun 29 – Jul 5, 2026');
|
||||
});
|
||||
|
||||
it('formats a week spanning two years', () => {
|
||||
expect(
|
||||
formatRecordCalendarWeekRange({
|
||||
firstDayOfWeek: Temporal.PlainDate.from('2025-12-29'),
|
||||
lastDayOfWeek: Temporal.PlainDate.from('2026-01-04'),
|
||||
locale: enUS,
|
||||
}),
|
||||
).toBe('Dec 29, 2025 – Jan 4, 2026');
|
||||
});
|
||||
});
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import { getRecordCalendarWeekEventDropDateTime } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekEventDropDateTime';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
describe('getRecordCalendarWeekEventDropDateTime', () => {
|
||||
it('moves a multi-day event start to the drop position from any fragment', () => {
|
||||
expect(
|
||||
getRecordCalendarWeekEventDropDateTime({
|
||||
destinationDay: Temporal.PlainDate.from('2026-07-10'),
|
||||
destinationMinutes: 10 * 60,
|
||||
startDateTime: '2026-07-07T07:00:00Z',
|
||||
endDateTime: '2026-07-09T10:00:00Z',
|
||||
timeZone: 'Europe/Paris',
|
||||
}),
|
||||
).toEqual({
|
||||
startDateTime: '2026-07-10T08:00:00Z',
|
||||
endDateTime: '2026-07-12T11:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('lands at the drop time when the original start is the later repeated DST time', () => {
|
||||
expect(
|
||||
getRecordCalendarWeekEventDropDateTime({
|
||||
destinationDay: Temporal.PlainDate.from('2026-10-26'),
|
||||
destinationMinutes: 10 * 60,
|
||||
startDateTime: '2026-10-25T01:30:00Z',
|
||||
endDateTime: '2026-10-25T02:30:00Z',
|
||||
timeZone: 'Europe/Paris',
|
||||
}),
|
||||
).toEqual({
|
||||
startDateTime: '2026-10-26T09:00:00Z',
|
||||
endDateTime: '2026-10-26T10:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('shifts an end that is equal to the start', () => {
|
||||
expect(
|
||||
getRecordCalendarWeekEventDropDateTime({
|
||||
destinationDay: Temporal.PlainDate.from('2026-07-10'),
|
||||
destinationMinutes: 10 * 60,
|
||||
startDateTime: '2026-07-07T07:00:00Z',
|
||||
endDateTime: '2026-07-07T07:00:00Z',
|
||||
timeZone: 'Europe/Paris',
|
||||
}),
|
||||
).toEqual({
|
||||
startDateTime: '2026-07-10T08:00:00Z',
|
||||
endDateTime: '2026-07-10T08:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([undefined, null, 42])(
|
||||
'returns null for a non-string start: %s',
|
||||
(startDateTime) => {
|
||||
expect(
|
||||
getRecordCalendarWeekEventDropDateTime({
|
||||
destinationDay: Temporal.PlainDate.from('2026-07-10'),
|
||||
destinationMinutes: 10 * 60,
|
||||
startDateTime,
|
||||
timeZone: 'Europe/Paris',
|
||||
}),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('returns null for a malformed start', () => {
|
||||
expect(
|
||||
getRecordCalendarWeekEventDropDateTime({
|
||||
destinationDay: Temporal.PlainDate.from('2026-07-10'),
|
||||
destinationMinutes: 10 * 60,
|
||||
startDateTime: 'not-a-date',
|
||||
timeZone: 'Europe/Paris',
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing', undefined],
|
||||
['non-string', 42],
|
||||
['malformed', 'not-a-date'],
|
||||
['before the start', '2026-07-07T06:59:00Z'],
|
||||
])('moves only the start when the end is %s', (_label, endDateTime) => {
|
||||
expect(
|
||||
getRecordCalendarWeekEventDropDateTime({
|
||||
destinationDay: Temporal.PlainDate.from('2026-07-10'),
|
||||
destinationMinutes: 10 * 60,
|
||||
startDateTime: '2026-07-07T07:00:00Z',
|
||||
endDateTime,
|
||||
timeZone: 'Europe/Paris',
|
||||
}),
|
||||
).toEqual({
|
||||
startDateTime: '2026-07-10T08:00:00Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { getRecordCalendarWeekEventHorizontalPosition } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekEventHorizontalPosition';
|
||||
|
||||
describe('getRecordCalendarWeekEventHorizontalPosition', () => {
|
||||
it('uses the available width for a non-overlapping event', () => {
|
||||
expect(
|
||||
getRecordCalendarWeekEventHorizontalPosition({
|
||||
columnCount: 1,
|
||||
columnIndex: 0,
|
||||
}),
|
||||
).toEqual({
|
||||
hoverStackingOrder: 2,
|
||||
left: 'calc(0% + 4px)',
|
||||
stackingOrder: 1,
|
||||
width: 'calc(100% - 8px)',
|
||||
});
|
||||
});
|
||||
|
||||
it('cascades overlapping events from their column to the right edge', () => {
|
||||
expect(
|
||||
Array.from({ length: 5 }, (_, columnIndex) =>
|
||||
getRecordCalendarWeekEventHorizontalPosition({
|
||||
columnCount: 5,
|
||||
columnIndex,
|
||||
}),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
hoverStackingOrder: 6,
|
||||
left: 'calc(0% + 4px)',
|
||||
stackingOrder: 1,
|
||||
width: 'calc(100% - 8px)',
|
||||
},
|
||||
{
|
||||
hoverStackingOrder: 6,
|
||||
left: 'calc(20% + 2.8px)',
|
||||
stackingOrder: 2,
|
||||
width: 'calc(80% - 6.8px)',
|
||||
},
|
||||
{
|
||||
hoverStackingOrder: 6,
|
||||
left: 'calc(40% + 1.6px)',
|
||||
stackingOrder: 3,
|
||||
width: 'calc(60% - 5.6px)',
|
||||
},
|
||||
{
|
||||
hoverStackingOrder: 6,
|
||||
left: 'calc(60% + 0.4px)',
|
||||
stackingOrder: 4,
|
||||
width: 'calc(40% - 4.4px)',
|
||||
},
|
||||
{
|
||||
hoverStackingOrder: 6,
|
||||
left: 'calc(80% - 0.8px)',
|
||||
stackingOrder: 5,
|
||||
width: 'calc(20% - 3.2px)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { RECORD_CALENDAR_WEEK_DIMENSIONS } from '@/object-record/record-calendar/week/constants/RecordCalendarWeekDimensions';
|
||||
import { getRecordCalendarWeekSlotIndex } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekSlotIndex';
|
||||
|
||||
const columnTop = 100;
|
||||
const columnHeight = RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight;
|
||||
|
||||
describe('getRecordCalendarWeekSlotIndex', () => {
|
||||
it.each([
|
||||
[columnTop, 0],
|
||||
[columnTop + 23.9, 0],
|
||||
[columnTop + 24, 1],
|
||||
[columnTop + columnHeight - 0.1, 47],
|
||||
])('maps pointer position %s to slot %s', (pointerY, expectedSlot) => {
|
||||
expect(
|
||||
getRecordCalendarWeekSlotIndex({
|
||||
columnHeight,
|
||||
columnTop,
|
||||
pointerY,
|
||||
}),
|
||||
).toBe(expectedSlot);
|
||||
});
|
||||
|
||||
it.each([columnTop - 0.1, columnTop + columnHeight])(
|
||||
'returns null outside the day column at %s',
|
||||
(pointerY) => {
|
||||
expect(
|
||||
getRecordCalendarWeekSlotIndex({
|
||||
columnHeight,
|
||||
columnTop,
|
||||
pointerY,
|
||||
}),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import { RECORD_CALENDAR_WEEK_DIMENSIONS } from '@/object-record/record-calendar/week/constants/RecordCalendarWeekDimensions';
|
||||
import {
|
||||
getRecordCalendarWeekTimedEventHeight,
|
||||
getRecordCalendarWeekTimedEventMetrics,
|
||||
} from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekTimedEventMetrics';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
const timeZone = 'Europe/Paris';
|
||||
const day = Temporal.PlainDate.from('2026-07-06');
|
||||
|
||||
describe('getRecordCalendarWeekTimedEventMetrics', () => {
|
||||
it('uses the configured duration', () => {
|
||||
expect(
|
||||
getRecordCalendarWeekTimedEventMetrics({
|
||||
day,
|
||||
startDateTime: '2026-07-06T07:00:00Z',
|
||||
endDateTime: '2026-07-06T08:30:00Z',
|
||||
timeZone,
|
||||
}),
|
||||
).toEqual({
|
||||
startInPixels: 9 * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
|
||||
endInPixels: 10.5 * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a one-hour event with the vertical inset from the design', () => {
|
||||
const metrics = getRecordCalendarWeekTimedEventMetrics({
|
||||
day,
|
||||
startDateTime: '2026-07-06T07:00:00Z',
|
||||
timeZone,
|
||||
});
|
||||
|
||||
expect(getRecordCalendarWeekTimedEventHeight(metrics!)).toBe(40);
|
||||
});
|
||||
|
||||
it.each([undefined, null, 'not-a-date', '2026-07-06T06:00:00Z'])(
|
||||
'falls back to one hour for an unusable end value: %s',
|
||||
(endDateTime) => {
|
||||
expect(
|
||||
getRecordCalendarWeekTimedEventMetrics({
|
||||
day,
|
||||
startDateTime: '2026-07-06T07:00:00Z',
|
||||
endDateTime,
|
||||
timeZone,
|
||||
}),
|
||||
).toEqual({
|
||||
startInPixels: 9 * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
|
||||
endInPixels: 10 * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps short events large enough to interact with', () => {
|
||||
const metrics = getRecordCalendarWeekTimedEventMetrics({
|
||||
day,
|
||||
startDateTime: '2026-07-06T07:00:00Z',
|
||||
endDateTime: '2026-07-06T07:05:00Z',
|
||||
timeZone,
|
||||
});
|
||||
|
||||
expect(metrics).toEqual({
|
||||
startInPixels: 9 * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
|
||||
endInPixels: 9.5 * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
|
||||
});
|
||||
expect(getRecordCalendarWeekTimedEventHeight(metrics!)).toBe(
|
||||
RECORD_CALENDAR_WEEK_DIMENSIONS.minimumEventSlotHeight,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the start-day fragment of a multi-day event to midnight', () => {
|
||||
expect(
|
||||
getRecordCalendarWeekTimedEventMetrics({
|
||||
day: Temporal.PlainDate.from('2026-07-08'),
|
||||
startDateTime: '2026-07-08T15:59:00Z',
|
||||
endDateTime: '2026-07-10T18:59:00Z',
|
||||
timeZone,
|
||||
}),
|
||||
).toEqual({
|
||||
startInPixels:
|
||||
(17 + 59 / 60) * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
|
||||
endInPixels: RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a full-day middle fragment of a multi-day event', () => {
|
||||
expect(
|
||||
getRecordCalendarWeekTimedEventMetrics({
|
||||
day: Temporal.PlainDate.from('2026-07-09'),
|
||||
startDateTime: '2026-07-08T15:59:00Z',
|
||||
endDateTime: '2026-07-10T18:59:00Z',
|
||||
timeZone,
|
||||
}),
|
||||
).toEqual({
|
||||
startInPixels: 0,
|
||||
endInPixels: RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the end-day fragment of a multi-day event to its end time', () => {
|
||||
expect(
|
||||
getRecordCalendarWeekTimedEventMetrics({
|
||||
day: Temporal.PlainDate.from('2026-07-10'),
|
||||
startDateTime: '2026-07-08T15:59:00Z',
|
||||
endDateTime: '2026-07-10T18:59:00Z',
|
||||
timeZone,
|
||||
}),
|
||||
).toEqual({
|
||||
startInPixels: 0,
|
||||
endInPixels: (20 + 59 / 60) * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render a multi-day event outside its range', () => {
|
||||
expect(
|
||||
getRecordCalendarWeekTimedEventMetrics({
|
||||
day: Temporal.PlainDate.from('2026-07-11'),
|
||||
startDateTime: '2026-07-08T15:59:00Z',
|
||||
endDateTime: '2026-07-10T18:59:00Z',
|
||||
timeZone,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('clips the one-hour fallback at the grid boundary', () => {
|
||||
expect(
|
||||
getRecordCalendarWeekTimedEventMetrics({
|
||||
day,
|
||||
startDateTime: '2026-07-06T21:30:00Z',
|
||||
timeZone,
|
||||
}),
|
||||
).toEqual({
|
||||
startInPixels: 23.5 * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
|
||||
endInPixels: RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight,
|
||||
});
|
||||
});
|
||||
|
||||
it('positions instants using the configured timezone', () => {
|
||||
expect(
|
||||
getRecordCalendarWeekTimedEventMetrics({
|
||||
day,
|
||||
startDateTime: '2026-07-06T09:00:00Z',
|
||||
timeZone,
|
||||
}),
|
||||
).toEqual({
|
||||
startInPixels: 11 * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
|
||||
endInPixels: 12 * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for an unusable start value', () => {
|
||||
expect(
|
||||
getRecordCalendarWeekTimedEventMetrics({
|
||||
day,
|
||||
startDateTime: 'not-a-date',
|
||||
timeZone,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { RECORD_CALENDAR_WEEK_DIMENSIONS } from '@/object-record/record-calendar/week/constants/RecordCalendarWeekDimensions';
|
||||
import { resolveRecordCalendarWeekEventDrop } from '@/object-record/record-calendar/week/utils/resolveRecordCalendarWeekEventDrop';
|
||||
|
||||
const gridRect = {
|
||||
height: RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight,
|
||||
left: 100,
|
||||
top: 200,
|
||||
width: 756,
|
||||
};
|
||||
|
||||
describe('resolveRecordCalendarWeekEventDrop', () => {
|
||||
it('resolves a day and snaps the event start to 30 minutes', () => {
|
||||
expect(
|
||||
resolveRecordCalendarWeekEventDrop({
|
||||
dayCount: 7,
|
||||
grabOffsetY: 10,
|
||||
gridRect,
|
||||
pointerX:
|
||||
gridRect.left +
|
||||
RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth +
|
||||
2.5 * 100,
|
||||
pointerY:
|
||||
gridRect.top + 10 * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight + 17,
|
||||
}),
|
||||
).toEqual({
|
||||
dayIndex: 2,
|
||||
destinationMinutes: 10 * 60,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['time gutter', gridRect.left + 20, gridRect.top + 100],
|
||||
['left of grid', gridRect.left - 1, gridRect.top + 100],
|
||||
['right of grid', gridRect.left + gridRect.width, gridRect.top + 100],
|
||||
[
|
||||
'above grid',
|
||||
gridRect.left + RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth + 10,
|
||||
gridRect.top - 1,
|
||||
],
|
||||
[
|
||||
'below grid',
|
||||
gridRect.left + RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth + 10,
|
||||
gridRect.top + gridRect.height,
|
||||
],
|
||||
])('rejects a drop in the %s', (_label, pointerX, pointerY) => {
|
||||
expect(
|
||||
resolveRecordCalendarWeekEventDrop({
|
||||
dayCount: 7,
|
||||
grabOffsetY: 0,
|
||||
gridRect,
|
||||
pointerX,
|
||||
pointerY,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('clamps the first event start to midnight', () => {
|
||||
expect(
|
||||
resolveRecordCalendarWeekEventDrop({
|
||||
dayCount: 7,
|
||||
grabOffsetY: 20,
|
||||
gridRect,
|
||||
pointerX:
|
||||
gridRect.left + RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth + 10,
|
||||
pointerY: gridRect.top + 1,
|
||||
}),
|
||||
).toMatchObject({ destinationMinutes: 0 });
|
||||
});
|
||||
|
||||
it('clamps the last event start to 23:30', () => {
|
||||
expect(
|
||||
resolveRecordCalendarWeekEventDrop({
|
||||
dayCount: 7,
|
||||
grabOffsetY: 0,
|
||||
gridRect,
|
||||
pointerX: gridRect.left + gridRect.width - 1,
|
||||
pointerY: gridRect.top + gridRect.height - 1,
|
||||
}),
|
||||
).toEqual({ dayIndex: 6, destinationMinutes: 23.5 * 60 });
|
||||
});
|
||||
});
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { updateRecordCalendarWeekActiveSlot } from '@/object-record/record-calendar/week/utils/updateRecordCalendarWeekActiveSlot';
|
||||
|
||||
describe('updateRecordCalendarWeekActiveSlot', () => {
|
||||
it('replaces the active slot when another day becomes active', () => {
|
||||
expect(
|
||||
updateRecordCalendarWeekActiveSlot({
|
||||
currentActiveSlot: {
|
||||
day: '2026-07-13',
|
||||
interactionMode: 'pointer',
|
||||
slotIndex: 18,
|
||||
},
|
||||
day: '2026-07-14',
|
||||
interactionMode: 'pointer',
|
||||
slotIndex: 20,
|
||||
}),
|
||||
).toEqual({
|
||||
day: '2026-07-14',
|
||||
interactionMode: 'pointer',
|
||||
slotIndex: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the active day', () => {
|
||||
expect(
|
||||
updateRecordCalendarWeekActiveSlot({
|
||||
currentActiveSlot: {
|
||||
day: '2026-07-13',
|
||||
interactionMode: 'pointer',
|
||||
slotIndex: 18,
|
||||
},
|
||||
day: '2026-07-13',
|
||||
interactionMode: 'pointer',
|
||||
slotIndex: null,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('does not clear another active day', () => {
|
||||
const currentActiveSlot = {
|
||||
day: '2026-07-14',
|
||||
interactionMode: 'pointer' as const,
|
||||
slotIndex: 20,
|
||||
};
|
||||
|
||||
expect(
|
||||
updateRecordCalendarWeekActiveSlot({
|
||||
currentActiveSlot,
|
||||
day: '2026-07-13',
|
||||
interactionMode: 'pointer',
|
||||
slotIndex: null,
|
||||
}),
|
||||
).toBe(currentActiveSlot);
|
||||
});
|
||||
|
||||
it('preserves the active slot reference when the pointer stays in one slot', () => {
|
||||
const currentActiveSlot = {
|
||||
day: '2026-07-13',
|
||||
interactionMode: 'pointer' as const,
|
||||
slotIndex: 18,
|
||||
};
|
||||
|
||||
expect(
|
||||
updateRecordCalendarWeekActiveSlot({
|
||||
currentActiveSlot,
|
||||
day: '2026-07-13',
|
||||
interactionMode: 'pointer',
|
||||
slotIndex: 18,
|
||||
}),
|
||||
).toBe(currentActiveSlot);
|
||||
});
|
||||
|
||||
it('keeps a keyboard slot active while the pointer moves', () => {
|
||||
const currentActiveSlot = {
|
||||
day: '2026-07-13',
|
||||
interactionMode: 'keyboard' as const,
|
||||
slotIndex: 18,
|
||||
};
|
||||
|
||||
expect(
|
||||
updateRecordCalendarWeekActiveSlot({
|
||||
currentActiveSlot,
|
||||
day: '2026-07-14',
|
||||
interactionMode: 'pointer',
|
||||
slotIndex: 20,
|
||||
}),
|
||||
).toBe(currentActiveSlot);
|
||||
});
|
||||
|
||||
it('does not clear a keyboard slot on pointer leave', () => {
|
||||
const currentActiveSlot = {
|
||||
day: '2026-07-13',
|
||||
interactionMode: 'keyboard' as const,
|
||||
slotIndex: 18,
|
||||
};
|
||||
|
||||
expect(
|
||||
updateRecordCalendarWeekActiveSlot({
|
||||
currentActiveSlot,
|
||||
day: '2026-07-13',
|
||||
interactionMode: 'pointer',
|
||||
slotIndex: null,
|
||||
}),
|
||||
).toBe(currentActiveSlot);
|
||||
});
|
||||
});
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
export type RecordCalendarWeekEventLayoutInput = {
|
||||
endInPixels: number;
|
||||
recordId: string;
|
||||
startInPixels: number;
|
||||
};
|
||||
|
||||
export type RecordCalendarWeekEventLayout =
|
||||
RecordCalendarWeekEventLayoutInput & {
|
||||
columnCount: number;
|
||||
columnIndex: number;
|
||||
};
|
||||
|
||||
const layoutOverlappingEventGroup = (
|
||||
events: RecordCalendarWeekEventLayoutInput[],
|
||||
): RecordCalendarWeekEventLayout[] => {
|
||||
const columnEndPositions: number[] = [];
|
||||
|
||||
const layouts = events.map((event) => {
|
||||
const availableColumnIndex = columnEndPositions.findIndex(
|
||||
(endInPixels) => endInPixels <= event.startInPixels,
|
||||
);
|
||||
|
||||
const columnIndex =
|
||||
availableColumnIndex === -1
|
||||
? columnEndPositions.length
|
||||
: availableColumnIndex;
|
||||
|
||||
columnEndPositions[columnIndex] = event.endInPixels;
|
||||
|
||||
return {
|
||||
...event,
|
||||
columnCount: 0,
|
||||
columnIndex,
|
||||
};
|
||||
});
|
||||
|
||||
const columnCount = columnEndPositions.length;
|
||||
|
||||
return layouts.map((layout) => ({
|
||||
...layout,
|
||||
columnCount,
|
||||
}));
|
||||
};
|
||||
|
||||
export const computeRecordCalendarWeekEventLayouts = (
|
||||
events: RecordCalendarWeekEventLayoutInput[],
|
||||
): RecordCalendarWeekEventLayout[] => {
|
||||
const sortedEvents = [...events].sort(
|
||||
(eventA, eventB) =>
|
||||
eventA.startInPixels - eventB.startInPixels ||
|
||||
eventB.endInPixels - eventA.endInPixels ||
|
||||
eventA.recordId.localeCompare(eventB.recordId),
|
||||
);
|
||||
|
||||
const overlappingEventGroups: RecordCalendarWeekEventLayoutInput[][] = [];
|
||||
let currentGroup: RecordCalendarWeekEventLayoutInput[] = [];
|
||||
let currentGroupEndInPixels = Number.NEGATIVE_INFINITY;
|
||||
|
||||
for (const event of sortedEvents) {
|
||||
if (
|
||||
currentGroup.length > 0 &&
|
||||
event.startInPixels >= currentGroupEndInPixels
|
||||
) {
|
||||
overlappingEventGroups.push(currentGroup);
|
||||
currentGroup = [];
|
||||
currentGroupEndInPixels = Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
currentGroup.push(event);
|
||||
currentGroupEndInPixels = Math.max(
|
||||
currentGroupEndInPixels,
|
||||
event.endInPixels,
|
||||
);
|
||||
}
|
||||
|
||||
if (currentGroup.length > 0) {
|
||||
overlappingEventGroups.push(currentGroup);
|
||||
}
|
||||
|
||||
return overlappingEventGroups.flatMap(layoutOverlappingEventGroup);
|
||||
};
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { getRecordCalendarDateTimeRange } from '@/object-record/record-calendar/utils/getRecordCalendarDateTimeRange';
|
||||
import { formatInTimeZone } from 'date-fns-tz';
|
||||
|
||||
type FormatRecordCalendarWeekEventTimesArgs = {
|
||||
endDateTime?: unknown;
|
||||
startDateTime: unknown;
|
||||
timeFormat: string;
|
||||
timeZone: string;
|
||||
};
|
||||
|
||||
export type RecordCalendarWeekEventTimes = {
|
||||
startTime: string;
|
||||
timeRange: string;
|
||||
};
|
||||
|
||||
export const formatRecordCalendarWeekEventTimes = ({
|
||||
endDateTime,
|
||||
startDateTime,
|
||||
timeFormat,
|
||||
timeZone,
|
||||
}: FormatRecordCalendarWeekEventTimesArgs): RecordCalendarWeekEventTimes | null => {
|
||||
const range = getRecordCalendarDateTimeRange({
|
||||
endDateTime,
|
||||
startDateTime,
|
||||
timeZone,
|
||||
});
|
||||
|
||||
if (range === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startTime = formatInTimeZone(
|
||||
range.start.epochMilliseconds,
|
||||
timeZone,
|
||||
timeFormat,
|
||||
);
|
||||
|
||||
if (range.isEndDateTimeFallback) {
|
||||
return { startTime, timeRange: startTime };
|
||||
}
|
||||
|
||||
const endTime = formatInTimeZone(
|
||||
range.end.epochMilliseconds,
|
||||
timeZone,
|
||||
timeFormat,
|
||||
);
|
||||
|
||||
return { startTime, timeRange: `${startTime} - ${endTime}` };
|
||||
};
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { format, type Locale } from 'date-fns';
|
||||
import { type Temporal } from 'temporal-polyfill';
|
||||
import { turnPlainDateToShiftedDateInSystemTimeZone } from 'twenty-shared/utils';
|
||||
|
||||
type FormatRecordCalendarWeekRangeArgs = {
|
||||
firstDayOfWeek: Temporal.PlainDate;
|
||||
lastDayOfWeek: Temporal.PlainDate;
|
||||
locale: Locale;
|
||||
};
|
||||
|
||||
export const formatRecordCalendarWeekRange = ({
|
||||
firstDayOfWeek,
|
||||
lastDayOfWeek,
|
||||
locale,
|
||||
}: FormatRecordCalendarWeekRangeArgs) => {
|
||||
const firstDay = turnPlainDateToShiftedDateInSystemTimeZone(firstDayOfWeek);
|
||||
const lastDay = turnPlainDateToShiftedDateInSystemTimeZone(lastDayOfWeek);
|
||||
const formatOptions = { locale };
|
||||
|
||||
if (firstDayOfWeek.year !== lastDayOfWeek.year) {
|
||||
return `${format(firstDay, 'MMM d, yyyy', formatOptions)} – ${format(
|
||||
lastDay,
|
||||
'MMM d, yyyy',
|
||||
formatOptions,
|
||||
)}`;
|
||||
}
|
||||
|
||||
if (firstDayOfWeek.month !== lastDayOfWeek.month) {
|
||||
return `${format(firstDay, 'MMM d', formatOptions)} – ${format(
|
||||
lastDay,
|
||||
'MMM d, yyyy',
|
||||
formatOptions,
|
||||
)}`;
|
||||
}
|
||||
|
||||
return `${format(firstDay, 'MMM d', formatOptions)} – ${format(
|
||||
lastDay,
|
||||
'd, yyyy',
|
||||
formatOptions,
|
||||
)}`;
|
||||
};
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import { getShiftedRecordCalendarEndDateTime } from '@/object-record/record-drag/utils/getShiftedRecordCalendarEndDateTime';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
type GetRecordCalendarWeekEventDropDateTimeArgs = {
|
||||
destinationDay: Temporal.PlainDate;
|
||||
destinationMinutes: number;
|
||||
endDateTime?: unknown;
|
||||
startDateTime?: unknown;
|
||||
timeZone: string;
|
||||
};
|
||||
|
||||
export const getRecordCalendarWeekEventDropDateTime = ({
|
||||
destinationDay,
|
||||
destinationMinutes,
|
||||
endDateTime,
|
||||
startDateTime,
|
||||
timeZone,
|
||||
}: GetRecordCalendarWeekEventDropDateTimeArgs) => {
|
||||
if (typeof startDateTime !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentStartInstant = Temporal.Instant.from(startDateTime);
|
||||
const shiftedStartInstant = destinationDay
|
||||
.toZonedDateTime({
|
||||
timeZone,
|
||||
plainTime: Temporal.PlainTime.from('00:00').add({
|
||||
nanoseconds: Math.round(destinationMinutes * 60 * 1_000_000_000),
|
||||
}),
|
||||
})
|
||||
.toInstant();
|
||||
const shiftedEndDateTime = getShiftedRecordCalendarEndDateTime({
|
||||
endDateTime,
|
||||
originalStartInstant: currentStartInstant,
|
||||
shiftedStartInstant,
|
||||
});
|
||||
|
||||
return {
|
||||
startDateTime: shiftedStartInstant.toString(),
|
||||
...(shiftedEndDateTime !== undefined && {
|
||||
endDateTime: shiftedEndDateTime,
|
||||
}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
const RECORD_CALENDAR_WEEK_EVENT_HORIZONTAL_INSET = 4;
|
||||
const RECORD_CALENDAR_WEEK_EVENT_COLUMN_GAP = 2;
|
||||
const RECORD_CALENDAR_WEEK_EVENT_POSITION_ROUNDING_FACTOR = 10_000;
|
||||
|
||||
type GetRecordCalendarWeekEventHorizontalPositionArgs = {
|
||||
columnCount: number;
|
||||
columnIndex: number;
|
||||
};
|
||||
|
||||
export type RecordCalendarWeekEventHorizontalPosition = {
|
||||
hoverStackingOrder: number;
|
||||
left: string;
|
||||
stackingOrder: number;
|
||||
width: string;
|
||||
};
|
||||
|
||||
const roundPositionValue = (value: number) =>
|
||||
Math.round(value * RECORD_CALENDAR_WEEK_EVENT_POSITION_ROUNDING_FACTOR) /
|
||||
RECORD_CALENDAR_WEEK_EVENT_POSITION_ROUNDING_FACTOR;
|
||||
|
||||
const formatPercentageWithPixelOffset = (
|
||||
percentage: number,
|
||||
pixelOffset: number,
|
||||
) =>
|
||||
pixelOffset < 0
|
||||
? `calc(${percentage}% - ${Math.abs(pixelOffset)}px)`
|
||||
: `calc(${percentage}% + ${pixelOffset}px)`;
|
||||
|
||||
export const getRecordCalendarWeekEventHorizontalPosition = ({
|
||||
columnCount,
|
||||
columnIndex,
|
||||
}: GetRecordCalendarWeekEventHorizontalPositionArgs): RecordCalendarWeekEventHorizontalPosition => {
|
||||
const columnSpan = columnCount - columnIndex;
|
||||
const leftPercentage = roundPositionValue((columnIndex * 100) / columnCount);
|
||||
const leftPixelOffset = roundPositionValue(
|
||||
RECORD_CALENDAR_WEEK_EVENT_HORIZONTAL_INSET -
|
||||
((RECORD_CALENDAR_WEEK_EVENT_HORIZONTAL_INSET * 2 -
|
||||
RECORD_CALENDAR_WEEK_EVENT_COLUMN_GAP) *
|
||||
columnIndex) /
|
||||
columnCount,
|
||||
);
|
||||
const widthPercentage = roundPositionValue((columnSpan * 100) / columnCount);
|
||||
const widthPixelReduction = roundPositionValue(
|
||||
RECORD_CALENDAR_WEEK_EVENT_COLUMN_GAP +
|
||||
((RECORD_CALENDAR_WEEK_EVENT_HORIZONTAL_INSET * 2 -
|
||||
RECORD_CALENDAR_WEEK_EVENT_COLUMN_GAP) *
|
||||
columnSpan) /
|
||||
columnCount,
|
||||
);
|
||||
|
||||
return {
|
||||
hoverStackingOrder: columnCount + 1,
|
||||
left: formatPercentageWithPixelOffset(leftPercentage, leftPixelOffset),
|
||||
stackingOrder: columnIndex + 1,
|
||||
width: `calc(${widthPercentage}% - ${widthPixelReduction}px)`,
|
||||
};
|
||||
};
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { RECORD_CALENDAR_WEEK_DIMENSIONS } from '@/object-record/record-calendar/week/constants/RecordCalendarWeekDimensions';
|
||||
|
||||
type GetRecordCalendarWeekSlotIndexArgs = {
|
||||
columnHeight: number;
|
||||
columnTop: number;
|
||||
pointerY: number;
|
||||
};
|
||||
|
||||
export const getRecordCalendarWeekSlotIndex = ({
|
||||
columnHeight,
|
||||
columnTop,
|
||||
pointerY,
|
||||
}: GetRecordCalendarWeekSlotIndexArgs): number | null => {
|
||||
if (pointerY < columnTop || pointerY >= columnTop + columnHeight) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const slotCount =
|
||||
(RECORD_CALENDAR_WEEK_DIMENSIONS.hoursInDay * 60) /
|
||||
RECORD_CALENDAR_WEEK_DIMENSIONS.snapIntervalInMinutes;
|
||||
|
||||
return Math.min(
|
||||
slotCount - 1,
|
||||
Math.floor(
|
||||
(pointerY - columnTop) / RECORD_CALENDAR_WEEK_DIMENSIONS.slotHeight,
|
||||
),
|
||||
);
|
||||
};
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import { RECORD_CALENDAR_WEEK_DIMENSIONS } from '@/object-record/record-calendar/week/constants/RecordCalendarWeekDimensions';
|
||||
import { getRecordCalendarDateTimeRange } from '@/object-record/record-calendar/utils/getRecordCalendarDateTimeRange';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
type GetRecordCalendarWeekTimedEventMetricsArgs = {
|
||||
day: Temporal.PlainDate;
|
||||
endDateTime?: unknown;
|
||||
startDateTime: unknown;
|
||||
timeZone: string;
|
||||
};
|
||||
|
||||
export type RecordCalendarWeekTimedEventMetrics = {
|
||||
endInPixels: number;
|
||||
startInPixels: number;
|
||||
};
|
||||
|
||||
export const getRecordCalendarWeekTimedEventHeight = ({
|
||||
endInPixels,
|
||||
startInPixels,
|
||||
}: RecordCalendarWeekTimedEventMetrics) =>
|
||||
Math.max(
|
||||
RECORD_CALENDAR_WEEK_DIMENSIONS.minimumEventSlotHeight,
|
||||
endInPixels -
|
||||
startInPixels -
|
||||
RECORD_CALENDAR_WEEK_DIMENSIONS.eventVerticalGap,
|
||||
);
|
||||
|
||||
const getTimeOfDayInPixels = (dateTime: Temporal.ZonedDateTime) =>
|
||||
(dateTime.hour + dateTime.minute / 60 + dateTime.second / 3600) *
|
||||
RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight;
|
||||
|
||||
export const getRecordCalendarWeekTimedEventMetrics = ({
|
||||
day,
|
||||
endDateTime,
|
||||
startDateTime,
|
||||
timeZone,
|
||||
}: GetRecordCalendarWeekTimedEventMetricsArgs): RecordCalendarWeekTimedEventMetrics | null => {
|
||||
const range = getRecordCalendarDateTimeRange({
|
||||
endDateTime,
|
||||
startDateTime,
|
||||
timeZone,
|
||||
});
|
||||
|
||||
if (range === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dayStart = day.toZonedDateTime({ timeZone });
|
||||
const nextDayStart = day.add({ days: 1 }).toZonedDateTime({ timeZone });
|
||||
|
||||
if (
|
||||
Temporal.Instant.compare(
|
||||
range.start.toInstant(),
|
||||
nextDayStart.toInstant(),
|
||||
) >= 0 ||
|
||||
Temporal.Instant.compare(range.end.toInstant(), dayStart.toInstant()) <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startInPixels =
|
||||
Temporal.Instant.compare(range.start.toInstant(), dayStart.toInstant()) <= 0
|
||||
? 0
|
||||
: getTimeOfDayInPixels(range.start);
|
||||
let endInPixels =
|
||||
Temporal.Instant.compare(range.end.toInstant(), nextDayStart.toInstant()) >=
|
||||
0
|
||||
? RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight
|
||||
: getTimeOfDayInPixels(range.end);
|
||||
|
||||
endInPixels = Math.min(
|
||||
RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight,
|
||||
Math.max(
|
||||
endInPixels,
|
||||
startInPixels + RECORD_CALENDAR_WEEK_DIMENSIONS.minimumEventSlotHeight,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
endInPixels,
|
||||
startInPixels,
|
||||
};
|
||||
};
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { RECORD_CALENDAR_WEEK_DIMENSIONS } from '@/object-record/record-calendar/week/constants/RecordCalendarWeekDimensions';
|
||||
|
||||
type CalendarGridRect = {
|
||||
height: number;
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
type ResolveRecordCalendarWeekEventDropArgs = {
|
||||
dayCount: number;
|
||||
grabOffsetY: number;
|
||||
gridRect: CalendarGridRect;
|
||||
pointerX: number;
|
||||
pointerY: number;
|
||||
};
|
||||
|
||||
export type RecordCalendarWeekEventDrop = {
|
||||
dayIndex: number;
|
||||
destinationMinutes: number;
|
||||
};
|
||||
|
||||
export const resolveRecordCalendarWeekEventDrop = ({
|
||||
dayCount,
|
||||
grabOffsetY,
|
||||
gridRect,
|
||||
pointerX,
|
||||
pointerY,
|
||||
}: ResolveRecordCalendarWeekEventDropArgs): RecordCalendarWeekEventDrop | null => {
|
||||
const daysLeft =
|
||||
gridRect.left + RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth;
|
||||
const daysWidth =
|
||||
gridRect.width - RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth;
|
||||
|
||||
if (
|
||||
dayCount <= 0 ||
|
||||
pointerX < daysLeft ||
|
||||
pointerX >= daysLeft + daysWidth ||
|
||||
pointerY < gridRect.top ||
|
||||
pointerY >= gridRect.top + gridRect.height
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dayIndex = Math.floor((pointerX - daysLeft) / (daysWidth / dayCount));
|
||||
const eventTopInPixels =
|
||||
pointerY -
|
||||
gridRect.top -
|
||||
grabOffsetY -
|
||||
RECORD_CALENDAR_WEEK_DIMENSIONS.eventVerticalGap / 2;
|
||||
const unsnappedMinutes =
|
||||
(eventTopInPixels / RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight) * 60;
|
||||
const destinationMinutes = Math.min(
|
||||
24 * 60 - RECORD_CALENDAR_WEEK_DIMENSIONS.snapIntervalInMinutes,
|
||||
Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
unsnappedMinutes /
|
||||
RECORD_CALENDAR_WEEK_DIMENSIONS.snapIntervalInMinutes,
|
||||
) * RECORD_CALENDAR_WEEK_DIMENSIONS.snapIntervalInMinutes,
|
||||
),
|
||||
);
|
||||
|
||||
return { dayIndex, destinationMinutes };
|
||||
};
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
export type RecordCalendarWeekActiveSlot = {
|
||||
day: string;
|
||||
interactionMode: RecordCalendarWeekSlotInteractionMode;
|
||||
slotIndex: number;
|
||||
};
|
||||
|
||||
export type RecordCalendarWeekSlotInteractionMode = 'keyboard' | 'pointer';
|
||||
|
||||
type UpdateRecordCalendarWeekActiveSlotArgs = {
|
||||
currentActiveSlot: RecordCalendarWeekActiveSlot | null;
|
||||
day: string;
|
||||
interactionMode: RecordCalendarWeekSlotInteractionMode;
|
||||
slotIndex: number | null;
|
||||
};
|
||||
|
||||
export const updateRecordCalendarWeekActiveSlot = ({
|
||||
currentActiveSlot,
|
||||
day,
|
||||
interactionMode,
|
||||
slotIndex,
|
||||
}: UpdateRecordCalendarWeekActiveSlotArgs): RecordCalendarWeekActiveSlot | null => {
|
||||
if (slotIndex === null) {
|
||||
return currentActiveSlot?.day === day &&
|
||||
currentActiveSlot.interactionMode === interactionMode
|
||||
? null
|
||||
: currentActiveSlot;
|
||||
}
|
||||
|
||||
if (
|
||||
currentActiveSlot?.interactionMode === 'keyboard' &&
|
||||
interactionMode === 'pointer'
|
||||
) {
|
||||
return currentActiveSlot;
|
||||
}
|
||||
|
||||
if (
|
||||
currentActiveSlot?.day === day &&
|
||||
currentActiveSlot.interactionMode === interactionMode &&
|
||||
currentActiveSlot.slotIndex === slotIndex
|
||||
) {
|
||||
return currentActiveSlot;
|
||||
}
|
||||
|
||||
return { day, interactionMode, slotIndex };
|
||||
};
|
||||
+113
-26
@@ -5,12 +5,16 @@ import { useStore } from 'jotai';
|
||||
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
|
||||
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
|
||||
import { calendarDayRecordIdsComponentFamilySelector } from '@/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector';
|
||||
import { getRecordIdFromRecordCalendarCardDraggableId } from '@/object-record/record-calendar/record-calendar-card/utils/getRecordCalendarCardDraggableId';
|
||||
|
||||
import { extractRecordPositions } from '@/object-record/record-drag/utils/extractRecordPositions';
|
||||
import { getShiftedRecordCalendarDateTime } from '@/object-record/record-drag/utils/getShiftedRecordCalendarDateTime';
|
||||
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { computeNewPositionOfDraggedRecord } from '@/object-record/utils/computeNewPositionOfDraggedRecord';
|
||||
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
|
||||
import { useAtomComponentFamilySelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorCallbackState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
@@ -21,6 +25,9 @@ export const useProcessCalendarCardDrop = () => {
|
||||
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
|
||||
const { currentView } = useGetCurrentViewOnly();
|
||||
const { updateOneRecord } = useUpdateOneRecord();
|
||||
const recordIndexCalendarEndFieldMetadataId = useAtomStateValue(
|
||||
recordIndexCalendarEndFieldMetadataIdState,
|
||||
);
|
||||
|
||||
const { userTimezone } = useUserTimezone();
|
||||
|
||||
@@ -37,11 +44,15 @@ export const useProcessCalendarCardDrop = () => {
|
||||
)
|
||||
return;
|
||||
|
||||
const { draggableId: recordId } = calendarCardDropResult;
|
||||
const recordId = getRecordIdFromRecordCalendarCardDraggableId(
|
||||
calendarCardDropResult.draggableId,
|
||||
);
|
||||
const destinationDate = calendarCardDropResult.destination.droppableId;
|
||||
const destinationIndex = calendarCardDropResult.destination.index;
|
||||
const sourceDate = calendarCardDropResult.source.droppableId;
|
||||
|
||||
const destinationPlainDate = Temporal.PlainDate.from(destinationDate);
|
||||
const sourcePlainDate = Temporal.PlainDate.from(sourceDate);
|
||||
|
||||
const record = store.get(recordStoreFamilyState.atomFamily(recordId));
|
||||
|
||||
@@ -50,16 +61,34 @@ export const useProcessCalendarCardDrop = () => {
|
||||
const calendarFieldMetadata = objectMetadataItem.fields.find(
|
||||
(field) => field.id === currentView.calendarFieldMetadataId,
|
||||
);
|
||||
const calendarEndFieldMetadata = objectMetadataItem.fields.find(
|
||||
(field) => field.id === recordIndexCalendarEndFieldMetadataId,
|
||||
);
|
||||
|
||||
if (!calendarFieldMetadata) return;
|
||||
|
||||
const destinationRecordIds = store.get(
|
||||
const destinationRecordIdsIncludingDraggedRecord = store.get(
|
||||
calendarDayRecordIdsSelector({
|
||||
day: destinationPlainDate,
|
||||
timeZone: userTimezone,
|
||||
}),
|
||||
);
|
||||
|
||||
const isCrossDayDrop = sourceDate !== destinationDate;
|
||||
const draggedRecordIndexInDestination =
|
||||
destinationRecordIdsIncludingDraggedRecord.indexOf(recordId);
|
||||
const destinationRecordIds = isCrossDayDrop
|
||||
? destinationRecordIdsIncludingDraggedRecord.filter(
|
||||
(destinationRecordId) => destinationRecordId !== recordId,
|
||||
)
|
||||
: destinationRecordIdsIncludingDraggedRecord;
|
||||
const adjustedDestinationIndex =
|
||||
isCrossDayDrop &&
|
||||
draggedRecordIndexInDestination !== -1 &&
|
||||
draggedRecordIndexInDestination < destinationIndex
|
||||
? destinationIndex - 1
|
||||
: destinationIndex;
|
||||
|
||||
const targetDayIsEmpty = destinationRecordIds.length === 0;
|
||||
|
||||
let newPosition: number;
|
||||
@@ -77,13 +106,13 @@ export const useProcessCalendarCardDrop = () => {
|
||||
|
||||
const isDroppedAfterList =
|
||||
(recordsWithPosition.length === 2 &&
|
||||
destinationIndex === 1 &&
|
||||
adjustedDestinationIndex === 1 &&
|
||||
!droppedRecordIsFromAnotherList) ||
|
||||
destinationIndex === recordsWithPosition.length;
|
||||
adjustedDestinationIndex === recordsWithPosition.length;
|
||||
|
||||
const targetRecord = isDroppedAfterList
|
||||
? recordsWithPosition.at(-1)
|
||||
: recordsWithPosition.at(destinationIndex);
|
||||
: recordsWithPosition.at(adjustedDestinationIndex);
|
||||
|
||||
if (!isDefined(targetRecord)) {
|
||||
throw new Error(
|
||||
@@ -104,32 +133,89 @@ export const useProcessCalendarCardDrop = () => {
|
||||
| undefined;
|
||||
|
||||
if (calendarFieldMetadata.type === FieldMetadataType.DATE) {
|
||||
await updateOneRecord({
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
idToUpdate: recordId,
|
||||
updateOneRecordInput: {
|
||||
[calendarFieldMetadata.name]: destinationPlainDate.toString(),
|
||||
position: newPosition,
|
||||
},
|
||||
});
|
||||
} else if (calendarFieldMetadata.type === FieldMetadataType.DATE_TIME) {
|
||||
const newDate = isDefined(currentFieldValue)
|
||||
? Temporal.Instant.from(currentFieldValue)
|
||||
.toZonedDateTimeISO(userTimezone)
|
||||
.with({
|
||||
day: destinationPlainDate.day,
|
||||
month: destinationPlainDate.month,
|
||||
year: destinationPlainDate.year,
|
||||
})
|
||||
: Temporal.PlainDate.from(destinationPlainDate).toZonedDateTime(
|
||||
userTimezone,
|
||||
);
|
||||
let shiftedStartDate = destinationPlainDate.toString();
|
||||
let shiftedEndDate: string | undefined;
|
||||
|
||||
if (isDefined(currentFieldValue)) {
|
||||
try {
|
||||
const currentStartDate = Temporal.PlainDate.from(currentFieldValue);
|
||||
const dayOffset = sourcePlainDate.until(destinationPlainDate).days;
|
||||
|
||||
shiftedStartDate = currentStartDate
|
||||
.add({ days: dayOffset })
|
||||
.toString();
|
||||
|
||||
if (calendarEndFieldMetadata?.type === FieldMetadataType.DATE) {
|
||||
const currentEndFieldValue = record[
|
||||
calendarEndFieldMetadata.name
|
||||
] as string | undefined;
|
||||
|
||||
if (isDefined(currentEndFieldValue)) {
|
||||
const currentEndDate =
|
||||
Temporal.PlainDate.from(currentEndFieldValue);
|
||||
|
||||
if (
|
||||
Temporal.PlainDate.compare(
|
||||
currentEndDate,
|
||||
currentStartDate,
|
||||
) >= 0
|
||||
) {
|
||||
shiftedEndDate = currentEndDate
|
||||
.add({ days: dayOffset })
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
shiftedStartDate = destinationPlainDate.toString();
|
||||
shiftedEndDate = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
await updateOneRecord({
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
idToUpdate: recordId,
|
||||
updateOneRecordInput: {
|
||||
[calendarFieldMetadata.name]: newDate.toInstant().toString(),
|
||||
[calendarFieldMetadata.name]: shiftedStartDate,
|
||||
...(isDefined(calendarEndFieldMetadata) &&
|
||||
isDefined(shiftedEndDate) && {
|
||||
[calendarEndFieldMetadata.name]: shiftedEndDate,
|
||||
}),
|
||||
position: newPosition,
|
||||
},
|
||||
});
|
||||
} else if (calendarFieldMetadata.type === FieldMetadataType.DATE_TIME) {
|
||||
let shiftedDateTime = null;
|
||||
|
||||
if (isDefined(currentFieldValue)) {
|
||||
shiftedDateTime = getShiftedRecordCalendarDateTime({
|
||||
sourceDay: sourcePlainDate,
|
||||
destinationDay: destinationPlainDate,
|
||||
startDateTime: currentFieldValue,
|
||||
endDateTime:
|
||||
calendarEndFieldMetadata?.type === FieldMetadataType.DATE_TIME
|
||||
? record[calendarEndFieldMetadata.name]
|
||||
: undefined,
|
||||
timeZone: userTimezone,
|
||||
});
|
||||
}
|
||||
|
||||
const shiftedStartDateTime =
|
||||
shiftedDateTime?.startDateTime ??
|
||||
destinationPlainDate
|
||||
.toZonedDateTime({ timeZone: userTimezone })
|
||||
.toInstant()
|
||||
.toString();
|
||||
|
||||
await updateOneRecord({
|
||||
objectNameSingular: objectMetadataItem.nameSingular,
|
||||
idToUpdate: recordId,
|
||||
updateOneRecordInput: {
|
||||
[calendarFieldMetadata.name]: shiftedStartDateTime,
|
||||
...(isDefined(calendarEndFieldMetadata) &&
|
||||
isDefined(shiftedDateTime?.endDateTime) && {
|
||||
[calendarEndFieldMetadata.name]: shiftedDateTime.endDateTime,
|
||||
}),
|
||||
position: newPosition,
|
||||
},
|
||||
});
|
||||
@@ -143,6 +229,7 @@ export const useProcessCalendarCardDrop = () => {
|
||||
calendarDayRecordIdsSelector,
|
||||
userTimezone,
|
||||
updateOneRecord,
|
||||
recordIndexCalendarEndFieldMetadataId,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import { getShiftedRecordCalendarDateTime } from '@/object-record/record-drag/utils/getShiftedRecordCalendarDateTime';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
const timeZone = 'Europe/Paris';
|
||||
|
||||
describe('getShiftedRecordCalendarDateTime', () => {
|
||||
it('moves a timed event by the rendered day offset and preserves duration', () => {
|
||||
expect(
|
||||
getShiftedRecordCalendarDateTime({
|
||||
sourceDay: Temporal.PlainDate.from('2026-07-08'),
|
||||
destinationDay: Temporal.PlainDate.from('2026-07-11'),
|
||||
startDateTime: '2026-07-08T15:59:00Z',
|
||||
endDateTime: '2026-07-10T18:59:00Z',
|
||||
timeZone,
|
||||
}),
|
||||
).toEqual({
|
||||
startDateTime: '2026-07-11T15:59:00Z',
|
||||
endDateTime: '2026-07-13T18:59:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('moves a continuation fragment by its visual anchor', () => {
|
||||
expect(
|
||||
getShiftedRecordCalendarDateTime({
|
||||
sourceDay: Temporal.PlainDate.from('2026-07-10'),
|
||||
destinationDay: Temporal.PlainDate.from('2026-07-11'),
|
||||
startDateTime: '2026-07-08T15:59:00Z',
|
||||
endDateTime: '2026-07-10T18:59:00Z',
|
||||
timeZone,
|
||||
}),
|
||||
).toEqual({
|
||||
startDateTime: '2026-07-09T15:59:00Z',
|
||||
endDateTime: '2026-07-11T18:59:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('lands on the requested local time and preserves duration across DST', () => {
|
||||
expect(
|
||||
getShiftedRecordCalendarDateTime({
|
||||
sourceDay: Temporal.PlainDate.from('2026-03-28'),
|
||||
destinationDay: Temporal.PlainDate.from('2026-03-29'),
|
||||
startDateTime: '2026-03-28T09:00:00Z',
|
||||
endDateTime: '2026-03-28T11:00:00Z',
|
||||
timeZone,
|
||||
}),
|
||||
).toEqual({
|
||||
startDateTime: '2026-03-29T08:00:00Z',
|
||||
endDateTime: '2026-03-29T10:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the later offset when the source start is a repeated DST time', () => {
|
||||
expect(
|
||||
getShiftedRecordCalendarDateTime({
|
||||
sourceDay: Temporal.PlainDate.from('2026-10-25'),
|
||||
destinationDay: Temporal.PlainDate.from('2026-10-26'),
|
||||
startDateTime: '2026-10-25T01:30:00Z',
|
||||
endDateTime: '2026-10-25T02:30:00Z',
|
||||
timeZone,
|
||||
}),
|
||||
).toEqual({
|
||||
startDateTime: '2026-10-26T01:30:00Z',
|
||||
endDateTime: '2026-10-26T02:30:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the local start time when a continuation moves across DST', () => {
|
||||
expect(
|
||||
getShiftedRecordCalendarDateTime({
|
||||
sourceDay: Temporal.PlainDate.from('2026-03-29'),
|
||||
destinationDay: Temporal.PlainDate.from('2026-03-30'),
|
||||
startDateTime: '2026-03-28T09:00:00Z',
|
||||
endDateTime: '2026-03-30T10:00:00Z',
|
||||
timeZone,
|
||||
}),
|
||||
).toEqual({
|
||||
startDateTime: '2026-03-29T08:00:00Z',
|
||||
endDateTime: '2026-03-31T09:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not synthesize an end when it is unusable', () => {
|
||||
expect(
|
||||
getShiftedRecordCalendarDateTime({
|
||||
sourceDay: Temporal.PlainDate.from('2026-07-08'),
|
||||
destinationDay: Temporal.PlainDate.from('2026-07-09'),
|
||||
startDateTime: '2026-07-08T15:59:00Z',
|
||||
endDateTime: 'not-a-date',
|
||||
timeZone,
|
||||
}),
|
||||
).toEqual({
|
||||
startDateTime: '2026-07-09T15:59:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('shifts an end that is equal to the start', () => {
|
||||
expect(
|
||||
getShiftedRecordCalendarDateTime({
|
||||
sourceDay: Temporal.PlainDate.from('2026-07-08'),
|
||||
destinationDay: Temporal.PlainDate.from('2026-07-09'),
|
||||
startDateTime: '2026-07-08T15:59:00Z',
|
||||
endDateTime: '2026-07-08T15:59:00Z',
|
||||
timeZone,
|
||||
}),
|
||||
).toEqual({
|
||||
startDateTime: '2026-07-09T15:59:00Z',
|
||||
endDateTime: '2026-07-09T15:59:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for an unusable start', () => {
|
||||
expect(
|
||||
getShiftedRecordCalendarDateTime({
|
||||
sourceDay: Temporal.PlainDate.from('2026-07-08'),
|
||||
destinationDay: Temporal.PlainDate.from('2026-07-09'),
|
||||
startDateTime: 'not-a-date',
|
||||
timeZone,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { getShiftedRecordCalendarEndDateTime } from '@/object-record/record-drag/utils/getShiftedRecordCalendarEndDateTime';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
const originalStartInstant = Temporal.Instant.from('2026-07-07T07:00:00Z');
|
||||
const shiftedStartInstant = Temporal.Instant.from('2026-07-10T08:00:00Z');
|
||||
|
||||
describe('getShiftedRecordCalendarEndDateTime', () => {
|
||||
it('preserves the original elapsed duration from the shifted start', () => {
|
||||
expect(
|
||||
getShiftedRecordCalendarEndDateTime({
|
||||
endDateTime: '2026-07-09T10:00:00Z',
|
||||
originalStartInstant,
|
||||
shiftedStartInstant,
|
||||
}),
|
||||
).toBe('2026-07-12T11:00:00Z');
|
||||
});
|
||||
|
||||
it('preserves a zero-duration end', () => {
|
||||
expect(
|
||||
getShiftedRecordCalendarEndDateTime({
|
||||
endDateTime: originalStartInstant.toString(),
|
||||
originalStartInstant,
|
||||
shiftedStartInstant,
|
||||
}),
|
||||
).toBe(shiftedStartInstant.toString());
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing', undefined],
|
||||
['null', null],
|
||||
['non-string', 42],
|
||||
['malformed', 'not-a-date'],
|
||||
['before the start', '2026-07-07T06:59:00Z'],
|
||||
])('returns undefined when the end is %s', (_label, endDateTime) => {
|
||||
expect(
|
||||
getShiftedRecordCalendarEndDateTime({
|
||||
endDateTime,
|
||||
originalStartInstant,
|
||||
shiftedStartInstant,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { getShiftedRecordCalendarEndDateTime } from '@/object-record/record-drag/utils/getShiftedRecordCalendarEndDateTime';
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
type GetShiftedRecordCalendarDateTimeArgs = {
|
||||
destinationDay: Temporal.PlainDate;
|
||||
endDateTime?: unknown;
|
||||
sourceDay: Temporal.PlainDate;
|
||||
startDateTime?: unknown;
|
||||
timeZone: string;
|
||||
};
|
||||
|
||||
export type ShiftedRecordCalendarDateTime = {
|
||||
endDateTime?: string;
|
||||
startDateTime: string;
|
||||
};
|
||||
|
||||
export const getShiftedRecordCalendarDateTime = ({
|
||||
destinationDay,
|
||||
endDateTime,
|
||||
sourceDay,
|
||||
startDateTime,
|
||||
timeZone,
|
||||
}: GetShiftedRecordCalendarDateTimeArgs): ShiftedRecordCalendarDateTime | null => {
|
||||
if (typeof startDateTime !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const currentStartInstant = Temporal.Instant.from(startDateTime);
|
||||
const dayOffset = sourceDay.until(destinationDay).days;
|
||||
const shiftedStartInstant = currentStartInstant
|
||||
.toZonedDateTimeISO(timeZone)
|
||||
.add({ days: dayOffset })
|
||||
.toInstant();
|
||||
|
||||
const shiftedEndDateTime = getShiftedRecordCalendarEndDateTime({
|
||||
endDateTime,
|
||||
originalStartInstant: currentStartInstant,
|
||||
shiftedStartInstant,
|
||||
});
|
||||
|
||||
return {
|
||||
startDateTime: shiftedStartInstant.toString(),
|
||||
...(shiftedEndDateTime !== undefined && {
|
||||
endDateTime: shiftedEndDateTime,
|
||||
}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { Temporal } from 'temporal-polyfill';
|
||||
|
||||
type GetShiftedRecordCalendarEndDateTimeArgs = {
|
||||
endDateTime?: unknown;
|
||||
originalStartInstant: Temporal.Instant;
|
||||
shiftedStartInstant: Temporal.Instant;
|
||||
};
|
||||
|
||||
export const getShiftedRecordCalendarEndDateTime = ({
|
||||
endDateTime,
|
||||
originalStartInstant,
|
||||
shiftedStartInstant,
|
||||
}: GetShiftedRecordCalendarEndDateTimeArgs): string | undefined => {
|
||||
if (typeof endDateTime !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const originalEndInstant = Temporal.Instant.from(endDateTime);
|
||||
|
||||
if (
|
||||
Temporal.Instant.compare(originalEndInstant, originalStartInstant) < 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Temporal.Instant.fromEpochNanoseconds(
|
||||
shiftedStartInstant.epochNanoseconds +
|
||||
(originalEndInstant.epochNanoseconds -
|
||||
originalStartInstant.epochNanoseconds),
|
||||
).toString();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
+10
-8
@@ -17,12 +17,12 @@ import { filterDuplicatesById, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type UseRecordsUsefulGqlFields = {
|
||||
objectMetadataItem: EnrichedObjectMetadataItem;
|
||||
additionalFieldMetadataId?: string | null;
|
||||
additionalFieldMetadataIds?: Array<string | null | undefined>;
|
||||
};
|
||||
|
||||
export const useRelevantRecordsGqlFields = ({
|
||||
objectMetadataItem,
|
||||
additionalFieldMetadataId,
|
||||
additionalFieldMetadataIds = [],
|
||||
}: UseRecordsUsefulGqlFields) => {
|
||||
const visibleRecordFields = useAtomComponentSelectorValue(
|
||||
visibleRecordFieldsComponentSelector,
|
||||
@@ -52,16 +52,18 @@ export const useRelevantRecordsGqlFields = ({
|
||||
)
|
||||
.filter(isDefined);
|
||||
|
||||
const additionalFieldMetadataItem = isDefined(additionalFieldMetadataId)
|
||||
? fieldMetadataItemByFieldMetadataItemId[additionalFieldMetadataId]
|
||||
: undefined;
|
||||
const additionalFieldMetadataItems = additionalFieldMetadataIds
|
||||
.filter(isDefined)
|
||||
.map(
|
||||
(fieldMetadataId) =>
|
||||
fieldMetadataItemByFieldMetadataItemId[fieldMetadataId],
|
||||
)
|
||||
.filter(isDefined);
|
||||
|
||||
const fieldMetadataItemsToUse = [
|
||||
...visibleRecordFieldMetadataItems,
|
||||
...(recordFilterFields ?? []),
|
||||
...(isDefined(additionalFieldMetadataItem)
|
||||
? [additionalFieldMetadataItem]
|
||||
: []),
|
||||
...additionalFieldMetadataItems,
|
||||
].filter(filterDuplicatesById);
|
||||
|
||||
const allDepthOneGqlFields = generateDepthRecordGqlFieldsFromFields({
|
||||
|
||||
+14
-10
@@ -4,19 +4,26 @@ import { useLoadRecordIndexStates } from '@/object-record/record-index/hooks/use
|
||||
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
|
||||
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
|
||||
import { viewFromViewIdFamilySelector } from '@/views/states/selectors/viewFromViewIdFamilySelector';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
||||
|
||||
export const RecordIndexLoadBaseOnContextStoreEffect = () => {
|
||||
const { loadRecordIndexStates } = useLoadRecordIndexStates();
|
||||
const contextStoreCurrentViewId = useAtomComponentStateValue(
|
||||
contextStoreCurrentViewIdComponentState,
|
||||
);
|
||||
|
||||
const [loadedViewId, setLoadedViewId] = useState<string | undefined>(
|
||||
undefined,
|
||||
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
);
|
||||
|
||||
const currentViewLoadKey = isDefined(contextStoreCurrentViewId)
|
||||
? `${contextStoreCurrentViewId}-${isCalendarWeekViewEnabled}`
|
||||
: undefined;
|
||||
|
||||
const [loadedViewKey, setLoadedViewKey] = useState<string | undefined>();
|
||||
|
||||
const view = useAtomFamilySelectorValue(viewFromViewIdFamilySelector, {
|
||||
viewId: contextStoreCurrentViewId ?? '',
|
||||
});
|
||||
@@ -24,10 +31,7 @@ export const RecordIndexLoadBaseOnContextStoreEffect = () => {
|
||||
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isDefined(contextStoreCurrentViewId) &&
|
||||
loadedViewId === contextStoreCurrentViewId
|
||||
) {
|
||||
if (isDefined(currentViewLoadKey) && loadedViewKey === currentViewLoadKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -37,12 +41,12 @@ export const RecordIndexLoadBaseOnContextStoreEffect = () => {
|
||||
|
||||
if (isDefined(view)) {
|
||||
loadRecordIndexStates(view, objectMetadataItem);
|
||||
setLoadedViewId(contextStoreCurrentViewId);
|
||||
setLoadedViewKey(currentViewLoadKey);
|
||||
}
|
||||
}, [
|
||||
contextStoreCurrentViewId,
|
||||
currentViewLoadKey,
|
||||
loadRecordIndexStates,
|
||||
loadedViewId,
|
||||
loadedViewKey,
|
||||
objectMetadataItem,
|
||||
view,
|
||||
]);
|
||||
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import { RecordIndexLoadBaseOnContextStoreEffect } from '@/object-record/record-index/components/RecordIndexLoadBaseOnContextStoreEffect';
|
||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
||||
|
||||
jest.mock(
|
||||
'@/object-record/record-index/hooks/useLoadRecordIndexStates',
|
||||
() => ({
|
||||
useLoadRecordIndexStates: jest.fn(),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue',
|
||||
() => ({
|
||||
useAtomComponentStateValue: jest.fn(),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue',
|
||||
() => ({
|
||||
useAtomFamilySelectorValue: jest.fn(),
|
||||
}),
|
||||
);
|
||||
jest.mock(
|
||||
'@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow',
|
||||
() => ({
|
||||
useContextStoreObjectMetadataItemOrThrow: jest.fn(),
|
||||
}),
|
||||
);
|
||||
jest.mock('@/workspace/hooks/useIsFeatureEnabled', () => ({
|
||||
useIsFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
const useLoadRecordIndexStatesMock = jest.requireMock(
|
||||
'@/object-record/record-index/hooks/useLoadRecordIndexStates',
|
||||
).useLoadRecordIndexStates;
|
||||
const useAtomComponentStateValueMock = jest.requireMock(
|
||||
'@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue',
|
||||
).useAtomComponentStateValue;
|
||||
const useAtomFamilySelectorValueMock = jest.requireMock(
|
||||
'@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue',
|
||||
).useAtomFamilySelectorValue;
|
||||
const useContextStoreObjectMetadataItemOrThrowMock = jest.requireMock(
|
||||
'@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow',
|
||||
).useContextStoreObjectMetadataItemOrThrow;
|
||||
const useIsFeatureEnabledMock = jest.requireMock(
|
||||
'@/workspace/hooks/useIsFeatureEnabled',
|
||||
).useIsFeatureEnabled;
|
||||
|
||||
describe('RecordIndexLoadBaseOnContextStoreEffect', () => {
|
||||
const loadRecordIndexStates = jest.fn();
|
||||
const view = { id: 'view-id' };
|
||||
const objectMetadataItem = { id: 'object-metadata-id' };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useLoadRecordIndexStatesMock.mockReturnValue({ loadRecordIndexStates });
|
||||
useAtomComponentStateValueMock.mockReturnValue('view-id');
|
||||
useAtomFamilySelectorValueMock.mockReturnValue(view);
|
||||
useContextStoreObjectMetadataItemOrThrowMock.mockReturnValue({
|
||||
objectMetadataItem,
|
||||
});
|
||||
useIsFeatureEnabledMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('reloads the persisted view state when the week-view flag changes', () => {
|
||||
const { rerender } = render(<RecordIndexLoadBaseOnContextStoreEffect />);
|
||||
|
||||
expect(loadRecordIndexStates).toHaveBeenCalledTimes(1);
|
||||
expect(loadRecordIndexStates).toHaveBeenLastCalledWith(
|
||||
view,
|
||||
objectMetadataItem,
|
||||
);
|
||||
|
||||
rerender(<RecordIndexLoadBaseOnContextStoreEffect />);
|
||||
|
||||
expect(loadRecordIndexStates).toHaveBeenCalledTimes(1);
|
||||
|
||||
useIsFeatureEnabledMock.mockReturnValue(true);
|
||||
rerender(<RecordIndexLoadBaseOnContextStoreEffect />);
|
||||
|
||||
expect(loadRecordIndexStates).toHaveBeenCalledTimes(2);
|
||||
expect(useIsFeatureEnabledMock).toHaveBeenCalledWith(
|
||||
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
);
|
||||
});
|
||||
});
|
||||
+24
@@ -11,10 +11,14 @@ import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldM
|
||||
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
|
||||
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
|
||||
import { useSetRecordGroups } from '@/object-record/record-group/hooks/useSetRecordGroups';
|
||||
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
|
||||
import { getEffectiveRecordCalendarEndFieldMetadataId } from '@/object-record/record-calendar/utils/getEffectiveRecordCalendarEndFieldMetadataId';
|
||||
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
|
||||
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState';
|
||||
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
|
||||
|
||||
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
|
||||
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
|
||||
import { RECORD_BOARD_COLUMN_WIDTH } from '@/object-record/record-board/constants/RecordBoardColumnWidth';
|
||||
import { clampRecordBoardColumnWidth } from '@/object-record/record-board/utils/clampRecordBoardColumnWidth';
|
||||
import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState';
|
||||
@@ -38,13 +42,18 @@ import { mapViewFieldToRecordField } from '@/views/utils/mapViewFieldToRecordFie
|
||||
import { mapViewFieldsToColumnDefinitions } from '@/views/utils/mapViewFieldsToColumnDefinitions';
|
||||
import { mapViewFilterGroupsToRecordFilterGroups } from '@/views/utils/mapViewFilterGroupsToRecordFilterGroups';
|
||||
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { atom, useStore } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
||||
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
|
||||
|
||||
export const useLoadRecordIndexStates = () => {
|
||||
const store = useStore();
|
||||
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
|
||||
);
|
||||
|
||||
const contextStoreTargetedRecordsRuleAtom =
|
||||
useAtomComponentStateCallbackState(
|
||||
@@ -323,6 +332,20 @@ export const useLoadRecordIndexStates = () => {
|
||||
recordIndexCalendarFieldMetadataIdState.atom,
|
||||
view.calendarFieldMetadataId ?? null,
|
||||
);
|
||||
batchSet(
|
||||
recordIndexCalendarEndFieldMetadataIdState.atom,
|
||||
getEffectiveRecordCalendarEndFieldMetadataId({
|
||||
calendarEndFieldMetadataId: view.calendarEndFieldMetadataId,
|
||||
isCalendarWeekViewEnabled,
|
||||
}),
|
||||
);
|
||||
batchSet(
|
||||
recordIndexCalendarLayoutState.atom,
|
||||
getSupportedRecordCalendarLayout({
|
||||
calendarLayout: view.calendarLayout,
|
||||
isCalendarWeekViewEnabled,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
batchSet(
|
||||
@@ -379,6 +402,7 @@ export const useLoadRecordIndexStates = () => {
|
||||
getFieldMetadataItemByIdOrThrow,
|
||||
setRecordGroupsFromViewGroups,
|
||||
syncRecordIndexViewFields,
|
||||
isCalendarWeekViewEnabled,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ export const useRecordIndexGroupCommonQueryVariables = () => {
|
||||
|
||||
const recordGqlFields = useRelevantRecordsGqlFields({
|
||||
objectMetadataItem,
|
||||
additionalFieldMetadataId: recordIndexGroupFieldMetadataItem?.id,
|
||||
additionalFieldMetadataIds: [recordIndexGroupFieldMetadataItem?.id],
|
||||
});
|
||||
|
||||
const recordGroupDefinitions = useAtomComponentSelectorValue(
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const recordIndexCalendarEndFieldMetadataIdState = createAtomState<
|
||||
string | null
|
||||
>({
|
||||
key: 'recordIndexCalendarEndFieldMetadataIdState',
|
||||
defaultValue: null,
|
||||
});
|
||||
Reference in New Issue
Block a user