Baptiste Devessier
2025-12-10 10:31:36 +01:00
committed by GitHub
parent 7b98804ec1
commit fd54a2af5c
55 changed files with 774 additions and 110 deletions
@@ -1,9 +1,38 @@
import { RecordFieldList } from '@/object-record/record-field-list/components/RecordFieldList';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly';
import { isRecordFieldReadOnly } from '@/object-record/read-only/utils/isRecordFieldReadOnly';
import { RecordDetailSectionContainer } from '@/object-record/record-field-list/record-detail-section/components/RecordDetailSectionContainer';
import { RecordFieldListComponentInstanceContext } from '@/object-record/record-field-list/states/contexts/RecordFieldListComponentInstanceContext';
import { recordFieldListHoverPositionComponentState } from '@/object-record/record-field-list/states/recordFieldListHoverPositionComponentState';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext';
import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell';
import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox';
import { PropertyBoxSkeletonLoader } from '@/object-record/record-inline-cell/property-box/components/PropertyBoxSkeletonLoader';
import { useRecordShowContainerActions } from '@/object-record/record-show/hooks/useRecordShowContainerActions';
import { useRecordShowContainerData } from '@/object-record/record-show/hooks/useRecordShowContainerData';
import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { FieldsWidgetCellEditModePortal } from '@/page-layout/widgets/fields/components/FieldsWidgetCellEditModePortal';
import { FieldsWidgetCellHoveredPortal } from '@/page-layout/widgets/fields/components/FieldsWidgetCellHoveredPortal';
import { useFieldsWidgetSectionsWithIndices } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetSectionsWithIndices';
import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { RightDrawerProvider } from '@/ui/layout/right-drawer/contexts/RightDrawerContext';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import styled from '@emotion/styled';
import { type PageLayoutWidget } from '~/generated/graphql';
import { t } from '@lingui/core/macro';
import {
AnimatedPlaceholder,
AnimatedPlaceholderEmptyContainer,
AnimatedPlaceholderEmptySubTitle,
AnimatedPlaceholderEmptyTextContainer,
AnimatedPlaceholderEmptyTitle,
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
} from 'twenty-ui/layout';
const StyledContainer = styled.div`
display: flex;
@@ -19,17 +48,148 @@ export const FieldsWidget = ({ widget: _widget }: FieldsWidgetProps) => {
const targetRecord = useTargetRecord();
const { isInRightDrawer } = useLayoutRenderingContext();
const instanceId = `fields-widget-${targetRecord.id}-${isInRightDrawer ? 'right-drawer' : ''}`;
const { recordLoading, isPrefetchLoading } = useRecordShowContainerData({
objectRecordId: targetRecord.id,
});
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: targetRecord.targetObjectNameSingular,
});
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({
objectNameSingular: targetRecord.targetObjectNameSingular,
objectRecordId: targetRecord.id,
});
const isRecordReadOnly = useIsRecordReadOnly({
recordId: targetRecord.id,
objectMetadataId: objectMetadataItem.id,
});
const setRecordFieldListHoverPosition = useSetRecoilComponentState(
recordFieldListHoverPositionComponentState,
instanceId,
);
const { sectionsWithFieldIndices } = useFieldsWidgetSectionsWithIndices(
targetRecord.targetObjectNameSingular,
);
if (sectionsWithFieldIndices.length === 0) {
return (
<RightDrawerProvider value={{ isInRightDrawer }}>
<StyledContainer>
<AnimatedPlaceholderEmptyContainer
// eslint-disable-next-line react/jsx-props-no-spreading
{...EMPTY_PLACEHOLDER_TRANSITION_PROPS}
>
<AnimatedPlaceholder type="noRecord" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
{t`No fields to display`}
</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
{t`Configure this widget to display fields`}
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
</AnimatedPlaceholderEmptyContainer>
</StyledContainer>
</RightDrawerProvider>
);
}
return (
<RightDrawerProvider value={{ isInRightDrawer }}>
<StyledContainer>
<RecordFieldList
instanceId={`fields-widget-${targetRecord.id}-${isInRightDrawer ? 'right-drawer' : ''}`}
objectNameSingular={targetRecord.targetObjectNameSingular}
objectRecordId={targetRecord.id}
// TODO: pick the value from the widget configuration
showDuplicatesSection={true}
<StyledContainer>
<RecordFieldListComponentInstanceContext.Provider
value={{
instanceId,
}}
>
{sectionsWithFieldIndices.map((section) => (
<RecordDetailSectionContainer key={section.id} title={section.title}>
<PropertyBox>
{isPrefetchLoading ? (
<PropertyBoxSkeletonLoader />
) : (
<>
{section.fields.map(
({ field: fieldMetadataItem, globalIndex }) => {
return (
<FieldContext.Provider
key={targetRecord.id + fieldMetadataItem.id}
value={{
recordId: targetRecord.id,
maxWidth: 200,
isLabelIdentifier: false,
fieldDefinition:
formatFieldMetadataItemAsColumnDefinition({
field: fieldMetadataItem,
position: globalIndex,
objectMetadataItem,
showLabel: true,
labelWidth: 90,
}),
useUpdateRecord: useUpdateOneObjectRecordMutation,
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: isRecordFieldReadOnly({
isRecordReadOnly,
objectPermissions:
getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
objectMetadataId: objectMetadataItem.id,
}),
fieldMetadataItem: {
id: fieldMetadataItem.id,
isUIReadOnly:
fieldMetadataItem.isUIReadOnly ?? false,
},
}),
onMouseEnter: () =>
setRecordFieldListHoverPosition(globalIndex),
anchorId: `${getRecordFieldInputInstanceId({
recordId: targetRecord.id,
fieldName: fieldMetadataItem.name,
prefix: instanceId,
})}`,
}}
>
<RecordFieldComponentInstanceContext.Provider
value={{
instanceId: getRecordFieldInputInstanceId({
recordId: targetRecord.id,
fieldName: fieldMetadataItem.name,
prefix: instanceId,
}),
}}
>
<RecordInlineCell
loading={recordLoading}
instanceIdPrefix={instanceId}
/>
</RecordFieldComponentInstanceContext.Provider>
</FieldContext.Provider>
);
},
)}
</>
)}
</PropertyBox>
</RecordDetailSectionContainer>
))}
<FieldsWidgetCellHoveredPortal
objectMetadataItem={objectMetadataItem}
recordId={targetRecord.id}
/>
</StyledContainer>
</RightDrawerProvider>
<FieldsWidgetCellEditModePortal
objectMetadataItem={objectMetadataItem}
recordId={targetRecord.id}
/>
</RecordFieldListComponentInstanceContext.Provider>
</StyledContainer>
);
};
@@ -0,0 +1,56 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RecordFieldListInputContextProvider } from '@/object-record/record-field-list/anchored-portal/components/RecordFieldListInputContextProvider';
import { RecordFieldListComponentInstanceContext } from '@/object-record/record-field-list/states/contexts/RecordFieldListComponentInstanceContext';
import { recordFieldListCellEditModePositionComponentState } from '@/object-record/record-field-list/states/recordFieldListCellEditModePositionComponentState';
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 { useFieldsWidgetFlattenedFields } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetFlattenedFields';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { isDefined } from 'twenty-shared/utils';
type FieldsWidgetCellEditModePortalProps = {
objectMetadataItem: ObjectMetadataItem;
recordId: string;
};
export const FieldsWidgetCellEditModePortal = ({
objectMetadataItem,
recordId,
}: FieldsWidgetCellEditModePortalProps) => {
const instanceId = useAvailableComponentInstanceIdOrThrow(
RecordFieldListComponentInstanceContext,
);
const editModePosition = useRecoilComponentValue(
recordFieldListCellEditModePositionComponentState,
);
const { flattenedFieldMetadataItems } = useFieldsWidgetFlattenedFields(
objectMetadataItem.nameSingular,
);
const editedFieldMetadataItem = isDefined(editModePosition)
? flattenedFieldMetadataItems.at(editModePosition)
: undefined;
if (!isDefined(editModePosition) || !isDefined(editedFieldMetadataItem)) {
return null;
}
return (
<RecordInlineCellAnchoredPortal
fieldMetadataItem={editedFieldMetadataItem}
objectMetadataItem={objectMetadataItem}
recordId={recordId}
instanceIdPrefix={instanceId}
>
<RecordFieldListInputContextProvider>
<RecordInlineCellEditMode>
<FieldInput />
</RecordInlineCellEditMode>
</RecordFieldListInputContextProvider>
</RecordInlineCellAnchoredPortal>
);
};
@@ -0,0 +1,53 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RecordFieldListCellHoveredPortalContent } from '@/object-record/record-field-list/anchored-portal/components/RecordFieldListCellHoveredPortalContent';
import { RecordFieldListInputContextProvider } from '@/object-record/record-field-list/anchored-portal/components/RecordFieldListInputContextProvider';
import { RecordFieldListComponentInstanceContext } from '@/object-record/record-field-list/states/contexts/RecordFieldListComponentInstanceContext';
import { recordFieldListHoverPositionComponentState } from '@/object-record/record-field-list/states/recordFieldListHoverPositionComponentState';
import { RecordInlineCellAnchoredPortal } from '@/object-record/record-inline-cell/components/RecordInlineCellAnchoredPortal';
import { useFieldsWidgetFlattenedFields } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetFlattenedFields';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { isDefined } from 'twenty-shared/utils';
type FieldsWidgetCellHoveredPortalProps = {
objectMetadataItem: ObjectMetadataItem;
recordId: string;
};
export const FieldsWidgetCellHoveredPortal = ({
objectMetadataItem,
recordId,
}: FieldsWidgetCellHoveredPortalProps) => {
const instanceId = useAvailableComponentInstanceIdOrThrow(
RecordFieldListComponentInstanceContext,
);
const hoverPosition = useRecoilComponentValue(
recordFieldListHoverPositionComponentState,
);
const { flattenedFieldMetadataItems } = useFieldsWidgetFlattenedFields(
objectMetadataItem.nameSingular,
);
const hoveredFieldMetadataItem = isDefined(hoverPosition)
? flattenedFieldMetadataItems.at(hoverPosition)
: undefined;
if (!isDefined(hoverPosition) || !isDefined(hoveredFieldMetadataItem)) {
return null;
}
return (
<RecordInlineCellAnchoredPortal
fieldMetadataItem={hoveredFieldMetadataItem}
objectMetadataItem={objectMetadataItem}
recordId={recordId}
instanceIdPrefix={instanceId}
>
<RecordFieldListInputContextProvider>
<RecordFieldListCellHoveredPortalContent />
</RecordFieldListInputContextProvider>
</RecordInlineCellAnchoredPortal>
);
};
@@ -0,0 +1,29 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isFieldCellSupported } from '@/object-record/utils/isFieldCellSupported';
import { FieldMetadataType } from 'twenty-shared/types';
type UseFieldsWidgetFieldMetadataItemsProps = {
objectNameSingular: string;
};
export const useFieldsWidgetFieldMetadataItems = ({
objectNameSingular,
}: UseFieldsWidgetFieldMetadataItemsProps): FieldMetadataItem[] => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { objectMetadataItems } = useObjectMetadataItems();
const fieldMetadataItems = objectMetadataItem.readableFields.filter(
(fieldMetadataItem) =>
isFieldCellSupported(fieldMetadataItem, objectMetadataItems) &&
fieldMetadataItem.type !== FieldMetadataType.RELATION &&
fieldMetadataItem.type !== FieldMetadataType.MORPH_RELATION &&
fieldMetadataItem.type !== FieldMetadataType.RICH_TEXT_V2,
);
return fieldMetadataItems;
};
@@ -0,0 +1,12 @@
import { useFieldsWidgetSectionsWithFields } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetSectionsWithFields';
export const useFieldsWidgetFlattenedFields = (objectNameSingular: string) => {
const { sectionsWithFields } =
useFieldsWidgetSectionsWithFields(objectNameSingular);
const flattenedFieldMetadataItems = sectionsWithFields.flatMap(
(section) => section.fields,
);
return { flattenedFieldMetadataItems };
};
@@ -0,0 +1,31 @@
import { useTemporaryFieldsConfiguration } from '@/page-layout/hooks/useTemporaryFieldsConfiguration';
import { buildWidgetVisibilityContext } from '@/page-layout/utils/buildWidgetVisibilityContext';
import { useFieldsWidgetFieldMetadataItems } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetFieldMetadataItems';
import { filterAndOrderFieldsFromConfiguration } from '@/page-layout/widgets/fields/utils/filterAndOrderFieldsFromConfiguration';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
export const useFieldsWidgetSectionsWithFields = (
objectNameSingular: string,
) => {
const isMobile = useIsMobile();
const { isInRightDrawer } = useLayoutRenderingContext();
const fieldMetadataItems = useFieldsWidgetFieldMetadataItems({
objectNameSingular,
});
const temporaryConfiguration =
useTemporaryFieldsConfiguration(objectNameSingular);
const context = buildWidgetVisibilityContext({ isMobile, isInRightDrawer });
const sectionsWithFields = filterAndOrderFieldsFromConfiguration({
configuration: temporaryConfiguration ?? {
__typename: 'FieldsConfiguration',
sections: [],
},
availableFieldMetadataItems: fieldMetadataItems,
context,
});
return { sectionsWithFields };
};
@@ -0,0 +1,26 @@
import { useFieldsWidgetSectionsWithFields } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetSectionsWithFields';
export const useFieldsWidgetSectionsWithIndices = (
objectNameSingular: string,
) => {
const { sectionsWithFields } =
useFieldsWidgetSectionsWithFields(objectNameSingular);
const sectionsWithFieldIndices = sectionsWithFields.map(
(section, sectionIndex) => {
const startIndex = sectionsWithFields
.slice(0, sectionIndex)
.reduce((sum, s) => sum + s.fields.length, 0);
return {
...section,
fields: section.fields.map((field, fieldIndex) => ({
field,
globalIndex: startIndex + fieldIndex,
})),
};
},
);
return { sectionsWithFieldIndices };
};
@@ -0,0 +1,76 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type FieldsConfiguration } from '@/page-layout/types/FieldsConfiguration';
import { type WidgetVisibilityContext } from '@/page-layout/types/WidgetVisibilityContext';
import { evaluateWidgetVisibility } from '@/page-layout/utils/evaluateWidgetVisibility';
import { isDefined } from 'twenty-shared/utils';
export type FieldsConfigurationSectionWithFields = {
id: string;
title: string;
position: number;
fields: FieldMetadataItem[];
};
type FilterAndOrderFieldsFromConfigurationParams = {
configuration: FieldsConfiguration;
availableFieldMetadataItems: FieldMetadataItem[];
context: WidgetVisibilityContext;
};
export const filterAndOrderFieldsFromConfiguration = ({
configuration,
availableFieldMetadataItems,
context,
}: FilterAndOrderFieldsFromConfigurationParams): FieldsConfigurationSectionWithFields[] => {
const fieldMetadataItemsMap = new Map(
availableFieldMetadataItems.map((field) => [field.id, field]),
);
const sortedSections = [...configuration.sections].sort(
(a, b) => a.position - b.position,
);
const sectionsWithFields = sortedSections
.map((section) => {
const sortedFields = [...section.fields].sort(
(a, b) => a.position - b.position,
);
const visibleFields = sortedFields
.map((fieldConfig) => {
const fieldMetadataItem = fieldMetadataItemsMap.get(
fieldConfig.fieldMetadataId,
);
if (!isDefined(fieldMetadataItem)) {
return null;
}
const isVisible = evaluateWidgetVisibility({
conditionalDisplay: fieldConfig.conditionalDisplay,
context,
});
if (!isVisible) {
return null;
}
return fieldMetadataItem;
})
.filter(isDefined);
if (visibleFields.length === 0) {
return null;
}
return {
id: section.id,
title: section.title,
position: section.position,
fields: visibleFields,
};
})
.filter(isDefined);
return sectionsWithFields;
};