Calendar view: add field edition (#14514)

<img width="1919" height="956" alt="image"
src="https://github.com/user-attachments/assets/4829f6f9-7e53-45da-bc38-590ee2fbde98"
/>
This commit is contained in:
Charles Bochet
2025-09-15 18:03:06 +02:00
committed by GitHub
parent 2f1efa68ad
commit 755ffe0561
15 changed files with 430 additions and 39 deletions
@@ -1,29 +0,0 @@
import { RecordCalendarCardBody } from '@/object-record/record-calendar/card/components/RecordCalendarCardBody';
import { RecordCalendarCardHeader } from '@/object-record/record-calendar/card/components/RecordCalendarCardHeader';
import { RecordCard } from '@/object-record/record-card/components/RecordCard';
import styled from '@emotion/styled';
const StyledContainer = styled.div`
display: flex;
`;
const StyledRecordCard = styled(RecordCard)`
margin-bottom: ${({ theme }) => theme.spacing(1)};
`;
type RecordCalendarMonthBodyCardProps = {
recordId: string;
};
export const RecordCalendarMonthBodyCard = ({
recordId,
}: RecordCalendarMonthBodyCardProps) => {
return (
<StyledContainer>
<StyledRecordCard>
<RecordCalendarCardHeader recordId={recordId} />
<RecordCalendarCardBody recordId={recordId} isRecordReadOnly={false} />
</StyledRecordCard>
</StyledContainer>
);
};
@@ -1,4 +1,4 @@
import { RecordCalendarMonthBodyCard } from '@/object-record/record-calendar/month/components/RecordCalendarMonthBodyCard';
import { RecordCalendarCard } from '@/object-record/record-calendar/record-calendar-card/components/RecordCalendarCard';
import { calendarDayRecordIdsComponentFamilySelector } from '@/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector';
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import styled from '@emotion/styled';
@@ -39,7 +39,7 @@ export const RecordCalendarMonthBodyDay = ({
<StyledDayHeader>{day.getDate()}</StyledDayHeader>
<StyledCardsContainer>
{recordIds.slice(0, 5).map((recordId) => (
<RecordCalendarMonthBodyCard key={recordId} recordId={recordId} />
<RecordCalendarCard key={recordId} recordId={recordId} />
))}
</StyledCardsContainer>
</StyledContainer>
@@ -0,0 +1,48 @@
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
import { RecordCalendarCardInputContextProvider } from '@/object-record/record-calendar/record-calendar-card/anchored-portal/components/RecordCalendarCardInputContextProvider';
import { RECORD_CALENDAR_CARD_INPUT_ID_PREFIX } from '@/object-record/record-calendar/record-calendar-card/constants/RecordCalendarCardInputIdPrefix';
import { useRecordCalendarCardMetadataFromPosition } from '@/object-record/record-calendar/record-calendar-card/hooks/useRecordCalendarCardMetadataFromPosition';
import { recordCalendarCardEditModePositionComponentState } from '@/object-record/record-calendar/record-calendar-card/states/recordCalendarCardEditModePositionComponentState';
import { FieldInput } from '@/object-record/record-field/ui/components/FieldInput';
import { RecordInlineCellAnchoredPortal } from '@/object-record/record-inline-cell/components/RecordInlineCellAnchoredPortal';
import { RecordInlineCellEditMode } from '@/object-record/record-inline-cell/components/RecordInlineCellEditMode';
import { isDefined } from 'twenty-shared/utils';
type RecordCalendarCardCellEditModePortalProps = {
recordId: string;
};
export const RecordCalendarCardCellEditModePortal = ({
recordId,
}: RecordCalendarCardCellEditModePortalProps) => {
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
const editModePosition = useRecoilComponentValue(
recordCalendarCardEditModePositionComponentState,
);
const { editedFieldMetadataItem } =
useRecordCalendarCardMetadataFromPosition();
if (!isDefined(editModePosition) || !isDefined(editedFieldMetadataItem)) {
return null;
}
return (
<RecordInlineCellAnchoredPortal
position={editModePosition}
fieldMetadataItem={editedFieldMetadataItem}
objectMetadataItem={objectMetadataItem}
recordId={recordId}
instanceIdPrefix={RECORD_CALENDAR_CARD_INPUT_ID_PREFIX}
>
<RecordCalendarCardInputContextProvider>
<RecordInlineCellEditMode>
<FieldInput />
</RecordInlineCellEditMode>
</RecordCalendarCardInputContextProvider>
</RecordInlineCellAnchoredPortal>
);
};
@@ -0,0 +1,45 @@
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
import { RecordCalendarCardCellHoveredPortalContent } from '@/object-record/record-calendar/record-calendar-card/anchored-portal/components/RecordCalendarCardCellHoveredPortalContent';
import { RecordCalendarCardInputContextProvider } from '@/object-record/record-calendar/record-calendar-card/anchored-portal/components/RecordCalendarCardInputContextProvider';
import { RECORD_CALENDAR_CARD_INPUT_ID_PREFIX } from '@/object-record/record-calendar/record-calendar-card/constants/RecordCalendarCardInputIdPrefix';
import { useRecordCalendarCardMetadataFromPosition } from '@/object-record/record-calendar/record-calendar-card/hooks/useRecordCalendarCardMetadataFromPosition';
import { recordCalendarCardHoverPositionComponentState } from '@/object-record/record-calendar/record-calendar-card/states/recordCalendarCardHoverPositionComponentState';
import { RecordInlineCellAnchoredPortal } from '@/object-record/record-inline-cell/components/RecordInlineCellAnchoredPortal';
import { isDefined } from 'twenty-shared/utils';
type RecordCalendarCardCellHoveredPortalProps = {
recordId: string;
};
export const RecordCalendarCardCellHoveredPortal = ({
recordId,
}: RecordCalendarCardCellHoveredPortalProps) => {
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
const hoverPosition = useRecoilComponentValue(
recordCalendarCardHoverPositionComponentState,
);
const { hoveredFieldMetadataItem } =
useRecordCalendarCardMetadataFromPosition();
if (!isDefined(hoverPosition) || !isDefined(hoveredFieldMetadataItem)) {
return null;
}
return (
<RecordInlineCellAnchoredPortal
position={hoverPosition}
fieldMetadataItem={hoveredFieldMetadataItem}
objectMetadataItem={objectMetadataItem}
recordId={recordId}
instanceIdPrefix={RECORD_CALENDAR_CARD_INPUT_ID_PREFIX}
>
<RecordCalendarCardInputContextProvider>
<RecordCalendarCardCellHoveredPortalContent />
</RecordCalendarCardInputContextProvider>
</RecordInlineCellAnchoredPortal>
);
};
@@ -0,0 +1,70 @@
import { RECORD_CALENDAR_CARD_INPUT_ID_PREFIX } from '@/object-record/record-calendar/record-calendar-card/constants/RecordCalendarCardInputIdPrefix';
import { recordCalendarCardEditModePositionComponentState } from '@/object-record/record-calendar/record-calendar-card/states/recordCalendarCardEditModePositionComponentState';
import { recordCalendarCardHoverPositionComponentState } from '@/object-record/record-calendar/record-calendar-card/states/recordCalendarCardHoverPositionComponentState';
import { FieldDisplay } from '@/object-record/record-field/ui/components/FieldDisplay';
import { FieldInput } from '@/object-record/record-field/ui/components/FieldInput';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { useOpenFieldInputEditMode } from '@/object-record/record-field/ui/hooks/useOpenFieldInputEditMode';
import { useRecordInlineCellContext } from '@/object-record/record-inline-cell/components/RecordInlineCellContext';
import { RecordInlineCellDisplayMode } from '@/object-record/record-inline-cell/components/RecordInlineCellDisplayMode';
import { RecordInlineCellHoveredPortalContent } from '@/object-record/record-inline-cell/components/RecordInlineCellHoveredPortalContent';
import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell';
import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useContext } from 'react';
export const RecordCalendarCardCellHoveredPortalContent = () => {
const { editModeContentOnly, isCentered } = useRecordInlineCellContext();
const { isRecordFieldReadOnly, recordId, fieldDefinition } =
useContext(FieldContext);
const { openInlineCell } = useInlineCell(
getRecordFieldInputInstanceId({
recordId,
fieldName: fieldDefinition.metadata.fieldName,
prefix: RECORD_CALENDAR_CARD_INPUT_ID_PREFIX,
}),
);
const shouldContainerBeClickable =
!isRecordFieldReadOnly && !editModeContentOnly;
const [recordCalendarCardHoverPosition, setRecordCalendarCardHoverPosition] =
useRecoilComponentState(recordCalendarCardHoverPositionComponentState);
const setRecordCalendarCardEditModePosition = useSetRecoilComponentState(
recordCalendarCardEditModePositionComponentState,
);
const { openFieldInput } = useOpenFieldInputEditMode();
const handleClick = () => {
if (shouldContainerBeClickable) {
openInlineCell();
setRecordCalendarCardEditModePosition(recordCalendarCardHoverPosition);
openFieldInput({
fieldDefinition,
recordId,
prefix: RECORD_CALENDAR_CARD_INPUT_ID_PREFIX,
});
}
};
const handleMouseLeave = () => {
setRecordCalendarCardHoverPosition(null);
};
return (
<RecordInlineCellHoveredPortalContent
readonly={isRecordFieldReadOnly}
isCentered={isCentered}
onMouseLeave={handleMouseLeave}
>
<RecordInlineCellDisplayMode isHovered={true} onClick={handleClick}>
{editModeContentOnly ? <FieldInput /> : <FieldDisplay />}
</RecordInlineCellDisplayMode>
</RecordInlineCellHoveredPortalContent>
);
};
@@ -0,0 +1,126 @@
import { usePersistFieldFromFieldInputContext } from '@/object-record/record-field/ui/hooks/usePersistFieldFromFieldInputContext';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import { recordCalendarCardEditModePositionComponentState } from '@/object-record/record-calendar/record-calendar-card/states/recordCalendarCardEditModePositionComponentState';
import {
FieldInputEventContext,
type FieldInputClickOutsideEvent,
type FieldInputEvent,
} from '@/object-record/record-field/ui/contexts/FieldInputEventContext';
import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell';
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
import { useAvailableComponentInstanceId } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceId';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useCallback } from 'react';
import { useRecoilCallback } from 'recoil';
type RecordCalendarCardInputContextProviderProps = {
children: React.ReactNode;
};
export const RecordCalendarCardInputContextProvider = ({
children,
}: RecordCalendarCardInputContextProviderProps) => {
const { closeInlineCell } = useInlineCell();
const setRecordCalendarCardEditModePosition = useSetRecoilComponentState(
recordCalendarCardEditModePositionComponentState,
);
const closeInlineCellAndResetEditModePosition = useCallback(() => {
setRecordCalendarCardEditModePosition(null);
closeInlineCell();
}, [closeInlineCell, setRecordCalendarCardEditModePosition]);
const instanceId = useAvailableComponentInstanceId(
RecordFieldComponentInstanceContext,
);
const { persistFieldFromFieldInputContext } =
usePersistFieldFromFieldInputContext();
const handleEnter: FieldInputEvent = ({ newValue, skipPersist }) => {
if (skipPersist !== true) {
persistFieldFromFieldInputContext(newValue);
}
closeInlineCellAndResetEditModePosition();
};
const handleSubmit: FieldInputEvent = ({ newValue, skipPersist }) => {
if (skipPersist !== true) {
persistFieldFromFieldInputContext(newValue);
}
closeInlineCellAndResetEditModePosition();
};
const handleCancel = () => {
closeInlineCellAndResetEditModePosition();
};
const handleClickOutside: FieldInputClickOutsideEvent = useRecoilCallback(
({ snapshot }) =>
({ newValue, event, skipPersist }) => {
const currentFocusId = snapshot
.getLoadable(currentFocusIdSelector)
.getValue();
if (currentFocusId !== instanceId) {
return;
}
event?.preventDefault();
event?.stopImmediatePropagation();
if (skipPersist !== true) {
persistFieldFromFieldInputContext(newValue);
}
closeInlineCellAndResetEditModePosition();
},
[
closeInlineCellAndResetEditModePosition,
instanceId,
persistFieldFromFieldInputContext,
],
);
const handleEscape: FieldInputEvent = ({ newValue, skipPersist }) => {
if (skipPersist !== true) {
persistFieldFromFieldInputContext(newValue);
}
closeInlineCellAndResetEditModePosition();
};
const handleTab: FieldInputEvent = ({ newValue, skipPersist }) => {
if (skipPersist !== true) {
persistFieldFromFieldInputContext(newValue);
}
closeInlineCellAndResetEditModePosition();
};
const handleShiftTab: FieldInputEvent = ({ newValue, skipPersist }) => {
if (skipPersist !== true) {
persistFieldFromFieldInputContext(newValue);
}
closeInlineCellAndResetEditModePosition();
};
return (
<FieldInputEventContext.Provider
value={{
onCancel: handleCancel,
onEnter: handleEnter,
onEscape: handleEscape,
onClickOutside: handleClickOutside,
onShiftTab: handleShiftTab,
onSubmit: handleSubmit,
onTab: handleTab,
}}
>
{children}
</FieldInputEventContext.Provider>
);
};
@@ -0,0 +1,41 @@
import { RecordCalendarCardCellEditModePortal } from '@/object-record/record-calendar/record-calendar-card/anchored-portal/components/RecordCalendarCardCellEditModePortal';
import { RecordCalendarCardCellHoveredPortal } from '@/object-record/record-calendar/record-calendar-card/anchored-portal/components/RecordCalendarCardCellHoveredPortal';
import { RecordCalendarCardBody } from '@/object-record/record-calendar/record-calendar-card/components/RecordCalendarCardBody';
import { RecordCalendarCardHeader } from '@/object-record/record-calendar/record-calendar-card/components/RecordCalendarCardHeader';
import { RecordCalendarCardComponentInstanceContext } from '@/object-record/record-calendar/record-calendar-card/states/contexts/RecordCalendarCardComponentInstanceContext';
import { RecordCard } from '@/object-record/record-card/components/RecordCard';
import styled from '@emotion/styled';
const StyledContainer = styled.div`
display: flex;
`;
const StyledRecordCard = styled(RecordCard)`
margin-bottom: ${({ theme }) => theme.spacing(1)};
`;
type RecordCalendarCardProps = {
recordId: string;
};
export const RecordCalendarCard = ({ recordId }: RecordCalendarCardProps) => {
return (
<RecordCalendarCardComponentInstanceContext.Provider
value={{
instanceId: recordId,
}}
>
<StyledContainer>
<StyledRecordCard>
<RecordCalendarCardHeader recordId={recordId} />
<RecordCalendarCardBody
recordId={recordId}
isRecordReadOnly={false}
/>
</StyledRecordCard>
<RecordCalendarCardCellHoveredPortal recordId={recordId} />
<RecordCalendarCardCellEditModePortal recordId={recordId} />
</StyledContainer>
</RecordCalendarCardComponentInstanceContext.Provider>
);
};
@@ -1,7 +1,8 @@
import { isRecordFieldReadOnly } from '@/object-record/read-only/utils/isRecordFieldReadOnly';
import { StopPropagationContainer } from '@/object-record/record-board/record-board-card/components/StopPropagationContainer';
import { RECORD_BOARD_CARD_INPUT_ID_PREFIX } from '@/object-record/record-board/record-board-card/constants/RecordBoardCardInputIdPrefix';
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
import { RECORD_CALENDAR_CARD_INPUT_ID_PREFIX } from '@/object-record/record-calendar/record-calendar-card/constants/RecordCalendarCardInputIdPrefix';
import { recordCalendarCardHoverPositionComponentState } from '@/object-record/record-calendar/record-calendar-card/states/recordCalendarCardHoverPositionComponentState';
import { RecordCardBodyContainer } from '@/object-record/record-card/components/RecordCardBodyContainer';
import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
@@ -10,6 +11,7 @@ import { useRecordIndexContextOrThrow } from '@/object-record/record-index/conte
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
type RecordCalendarCardBodyProps = {
recordId: string;
@@ -36,9 +38,17 @@ export const RecordCalendarCardBody = ({
recordField.fieldMetadataItemId !== labelIdentifierFieldMetadataItem?.id,
);
const setRecordCalendarCardHoverPosition = useSetRecoilComponentState(
recordCalendarCardHoverPositionComponentState,
);
const handleMouseEnter = (index: number) => {
setRecordCalendarCardHoverPosition(index);
};
return (
<RecordCardBodyContainer>
{visibleRecordFieldsExceptLabelIdentifier.map((recordField) => {
{visibleRecordFieldsExceptLabelIdentifier.map((recordField, index) => {
const correspondingFieldDefinition =
fieldDefinitionByFieldMetadataItemId[recordField.fieldMetadataItemId];
@@ -63,8 +73,8 @@ export const RecordCalendarCardBody = ({
useUpdateRecord: () => [() => undefined, { loading: false }],
isDisplayModeFixHeight: true,
triggerEvent: 'CLICK',
anchorId: `${RECORD_BOARD_CARD_INPUT_ID_PREFIX}-${recordId}-${correspondingFieldDefinition.metadata.fieldName}`,
onMouseEnter: () => {},
anchorId: `${RECORD_CALENDAR_CARD_INPUT_ID_PREFIX}-${recordId}-${correspondingFieldDefinition.metadata.fieldName}`,
onMouseEnter: () => handleMouseEnter(index),
}}
>
<RecordFieldComponentInstanceContext.Provider
@@ -72,12 +82,12 @@ export const RecordCalendarCardBody = ({
instanceId: getRecordFieldInputInstanceId({
recordId,
fieldName: correspondingFieldDefinition.metadata.fieldName,
prefix: RECORD_BOARD_CARD_INPUT_ID_PREFIX,
prefix: RECORD_CALENDAR_CARD_INPUT_ID_PREFIX,
}),
}}
>
<RecordInlineCell
instanceIdPrefix={RECORD_BOARD_CARD_INPUT_ID_PREFIX}
instanceIdPrefix={RECORD_CALENDAR_CARD_INPUT_ID_PREFIX}
/>
</RecordFieldComponentInstanceContext.Provider>
</FieldContext.Provider>
@@ -0,0 +1 @@
export const RECORD_CALENDAR_CARD_INPUT_ID_PREFIX = 'record-calendar-card';
@@ -0,0 +1,57 @@
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
import { recordCalendarCardEditModePositionComponentState } from '@/object-record/record-calendar/record-calendar-card/states/recordCalendarCardEditModePositionComponentState';
import { recordCalendarCardHoverPositionComponentState } from '@/object-record/record-calendar/record-calendar-card/states/recordCalendarCardHoverPositionComponentState';
import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { isDefined } from 'twenty-shared/utils';
export const useRecordCalendarCardMetadataFromPosition = () => {
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
const hoverPosition = useRecoilComponentValue(
recordCalendarCardHoverPositionComponentState,
);
const editModePosition = useRecoilComponentValue(
recordCalendarCardEditModePositionComponentState,
);
const visibleRecordFields = useRecoilComponentValue(
visibleRecordFieldsComponentSelector,
);
const { labelIdentifierFieldMetadataItem } = useRecordIndexContextOrThrow();
const visibleRecordFieldsFiltered = visibleRecordFields.filter(
(recordField) =>
labelIdentifierFieldMetadataItem?.id !== recordField.fieldMetadataItemId,
);
const hoveredRecordField = isDefined(hoverPosition)
? visibleRecordFieldsFiltered.at(hoverPosition)
: undefined;
const editedRecordField = isDefined(editModePosition)
? visibleRecordFieldsFiltered.at(editModePosition)
: undefined;
const hoveredFieldMetadataItem = isDefined(hoveredRecordField)
? objectMetadataItem.fields.find(
(fieldMetadataItem) =>
fieldMetadataItem.id === hoveredRecordField.fieldMetadataItemId,
)
: undefined;
const editedFieldMetadataItem = isDefined(editedRecordField)
? objectMetadataItem.fields.find(
(fieldMetadataItem) =>
fieldMetadataItem.id === editedRecordField.fieldMetadataItemId,
)
: undefined;
return {
hoveredFieldMetadataItem,
editedFieldMetadataItem,
};
};
@@ -0,0 +1,4 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
export const RecordCalendarCardComponentInstanceContext =
createComponentInstanceContext();
@@ -0,0 +1,9 @@
import { RecordCalendarCardComponentInstanceContext } from '@/object-record/record-calendar/record-calendar-card/states/contexts/RecordCalendarCardComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const recordCalendarCardEditModePositionComponentState =
createComponentState<number | null>({
key: 'recordCalendarCardEditModePositionComponentState',
defaultValue: null,
componentInstanceContext: RecordCalendarCardComponentInstanceContext,
});
@@ -0,0 +1,9 @@
import { RecordCalendarCardComponentInstanceContext } from '@/object-record/record-calendar/record-calendar-card/states/contexts/RecordCalendarCardComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const recordCalendarCardHoverPositionComponentState =
createComponentState<number | null>({
key: 'recordCalendarCardHoverPositionComponentState',
defaultValue: null,
componentInstanceContext: RecordCalendarCardComponentInstanceContext,
});
@@ -1,6 +1,6 @@
import styled from '@emotion/styled';
const StyledBoardCardBody = styled.div`
const StyledCardBodyContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(0.5)};
@@ -18,4 +18,4 @@ const StyledBoardCardBody = styled.div`
}
`;
export { StyledBoardCardBody as RecordCardBodyContainer };
export { StyledCardBodyContainer as RecordCardBodyContainer };