Support junction relations in Field widget (#19518)

## Resolved relations in picker



https://github.com/user-attachments/assets/bf2281a4-825b-4638-9cb9-7f1be987b8b6
This commit is contained in:
Baptiste Devessier
2026-04-10 13:03:33 +02:00
committed by GitHub
parent ec7d19a3af
commit 001b7285e6
8 changed files with 617 additions and 47 deletions
@@ -79,13 +79,17 @@ export const useFieldListFieldMetadataItems = ({
: 'inlineFieldMetadataItems',
);
const { activityTargetFields, inlineRelationFields, boxedRelationFields } =
categorizeRelationFields({
relationFields: relationFieldMetadataItems ?? [],
objectNameSingular,
objectPermissionsByObjectMetadataId,
isJunctionRelationsEnabled,
});
const {
activityTargetFields,
inlineRelationFields,
junctionRelationFields,
boxedRelationFields,
} = categorizeRelationFields({
relationFields: relationFieldMetadataItems ?? [],
objectNameSingular,
objectPermissionsByObjectMetadataId,
isJunctionRelationsEnabled,
});
const allInlineFieldMetadataItems = [
...(inlineFieldMetadataItems ?? []),
@@ -95,6 +99,7 @@ export const useFieldListFieldMetadataItems = ({
return {
inlineFieldMetadataItems: allInlineFieldMetadataItems,
legacyActivityTargetFieldMetadataItems: activityTargetFields,
junctionRelationFieldMetadataItems: junctionRelationFields,
boxedRelationFieldMetadataItems: boxedRelationFields,
};
};
@@ -1,13 +1,23 @@
import { useStore } from 'jotai';
import { type ReactNode, useCallback, useContext } from 'react';
import { v4 } from 'uuid';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { getFieldMetadataItemById } from '@/object-metadata/utils/getFieldMetadataItemById';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useRecordFieldsScopeContextOrThrow } from '@/object-record/record-field-list/contexts/RecordFieldsScopeContext';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { useUpdateJunctionRelationFromCell } from '@/object-record/record-field/ui/hooks/useUpdateJunctionRelationFromCell';
import { useAddNewRecordAndOpenSidePanel } from '@/object-record/record-field/ui/meta-types/input/hooks/useAddNewRecordAndOpenSidePanel';
import { useUpdateRelationOneToManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useUpdateRelationOneToManyFieldInput';
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import { type FieldRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { extractTargetRecordsFromJunction } from '@/object-record/record-field/ui/utils/junction/extractTargetRecordsFromJunction';
import { getJoinColumnName } from '@/object-record/record-field/ui/utils/junction/getJoinColumnName';
import { getJunctionConfig } from '@/object-record/record-field/ui/utils/junction/getJunctionConfig';
import { getSourceJoinColumnName } from '@/object-record/record-field/ui/utils/junction/getSourceJoinColumnName';
import { hasJunctionConfig } from '@/object-record/record-field/ui/utils/junction/hasJunctionConfig';
import { MultipleRecordPicker } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPicker';
import { useMultipleRecordPickerOpen } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerOpen';
import { useMultipleRecordPickerPerformSearch } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch';
@@ -15,13 +25,16 @@ import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-r
import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState';
import { multipleRecordPickerSearchableObjectMetadataItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchableObjectMetadataItemsComponentState';
import { getRecordFieldCardRelationPickerDropdownId } from '@/object-record/record-show/utils/getRecordFieldCardRelationPickerDropdownId';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { buildRecordLabelPayload } from '@/object-record/utils/buildRecordLabelPayload';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { dropdownPlacementComponentState } from '@/ui/layout/dropdown/states/dropdownPlacementComponentState';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { CustomError, isDefined } from 'twenty-shared/utils';
import { IconPlus } from 'twenty-ui/display';
@@ -34,6 +47,7 @@ type RecordDetailRelationSectionDropdownToManyProps = {
export const RecordDetailRelationSectionDropdownToMany = ({
dropdownTriggerClickableComponent,
}: RecordDetailRelationSectionDropdownToManyProps) => {
const store = useStore();
const { scopeInstanceId } = useRecordFieldsScopeContextOrThrow();
const { recordId, fieldDefinition } = useContext(FieldContext);
const { fieldMetadataId } = fieldDefinition;
@@ -56,11 +70,51 @@ export const RecordDetailRelationSectionDropdownToMany = ({
);
}
const isJunctionRelation = hasJunctionConfig(fieldMetadataItem.settings);
const relationFieldDefinition =
fieldDefinition as FieldDefinition<FieldRelationMetadata>;
const { updateJunctionRelationFromCell, isJunctionConfigValid } =
useUpdateJunctionRelationFromCell({
fieldMetadataItem,
fieldDefinition: relationFieldDefinition,
recordId,
});
const junctionConfig =
isJunctionRelation && isJunctionConfigValid
? getJunctionConfig({
settings: fieldMetadataItem.settings,
relationObjectMetadataId:
relationFieldDefinition.metadata.relationObjectMetadataId,
sourceObjectMetadataId: objectMetadataItem.id,
objectMetadataItems,
})
: null;
const firstJunctionTargetField =
junctionConfig && !junctionConfig.isMorphRelation
? junctionConfig.targetFields[0]
: undefined;
const junctionTargetObjectMetadata = objectMetadataItems.find(
(item) =>
item.id === firstJunctionTargetField?.relation?.targetObjectMetadata.id,
);
const isMorphJunction = junctionConfig?.isMorphRelation ?? false;
const { objectMetadataItem: relationObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: relationObjectMetadataNameSingular,
});
const pickerObjectMetadataItem =
isJunctionRelation && isDefined(junctionTargetObjectMetadata)
? junctionTargetObjectMetadata
: relationObjectMetadataItem;
const relationFieldMetadataItem = relationObjectMetadataItem.fields.find(
({ id }) => id === relationFieldMetadataId,
);
@@ -78,6 +132,23 @@ export const RecordDetailRelationSectionDropdownToMany = ({
const relationRecords: ObjectRecord[] = (fieldValue as ObjectRecord[]) ?? [];
const pickerRecords =
isJunctionRelation &&
isDefined(junctionConfig) &&
isDefined(junctionTargetObjectMetadata)
? extractTargetRecordsFromJunction({
junctionRecords: relationRecords,
targetFields: junctionConfig.targetFields,
objectMetadataItems,
}).map((extracted) => ({
recordId: extracted.recordId,
objectMetadataId: extracted.objectMetadataId,
}))
: relationRecords.map((record) => ({
recordId: record.id,
objectMetadataId: pickerObjectMetadataItem.id,
}));
const dropdownId = getRecordFieldCardRelationPickerDropdownId({
fieldDefinition,
recordId,
@@ -107,6 +178,12 @@ export const RecordDetailRelationSectionDropdownToMany = ({
dropdownId,
);
const multipleRecordPickerPickableMorphItemsCallbackState =
useAtomComponentStateCallbackState(
multipleRecordPickerPickableMorphItemsComponentState,
dropdownId,
);
const { performSearch: multipleRecordPickerPerformSearch } =
useMultipleRecordPickerPerformSearch();
@@ -127,40 +204,181 @@ export const RecordDetailRelationSectionDropdownToMany = ({
recordId,
});
const { createOneRecord: createTargetRecord } = useCreateOneRecord({
objectNameSingular:
junctionTargetObjectMetadata?.nameSingular ??
relationObjectMetadataNameSingular,
});
const { createOneRecord: createJunctionRecord } = useCreateOneRecord({
objectNameSingular:
junctionConfig?.junctionObjectMetadata?.nameSingular ??
relationObjectMetadataNameSingular,
});
const handleOpenRelationPickerDropdown = () => {
const pickableMorphItems = pickerRecords.map((item) => ({
recordId: item.recordId,
objectMetadataId: item.objectMetadataId,
isSelected: true,
isMatchingSearchFilter: true,
}));
setMultipleRecordPickerSearchableObjectMetadataItems([
relationObjectMetadataItem,
pickerObjectMetadataItem,
]);
setMultipleRecordPickerSearchFilter('');
setMultipleRecordPickerPickableMorphItems(
relationRecords.map((record) => ({
recordId: record.id,
objectMetadataId: relationObjectMetadataItem.id,
isSelected: true,
isMatchingSearchFilter: true,
})),
);
setMultipleRecordPickerPickableMorphItems(pickableMorphItems);
openMultipleRecordPicker(dropdownId);
multipleRecordPickerPerformSearch({
multipleRecordPickerInstanceId: dropdownId,
forceSearchFilter: '',
forceSearchableObjectMetadataItems: [relationObjectMetadataItem],
forcePickableMorphItems: relationRecords.map((record) => ({
recordId: record.id,
objectMetadataId: relationObjectMetadataItem.id,
isSelected: true,
isMatchingSearchFilter: true,
})),
forceSearchableObjectMetadataItems: [pickerObjectMetadataItem],
forcePickableMorphItems: pickableMorphItems,
});
};
const handleCreateNew = (searchString?: string) => {
closeDropdown(dropdownId);
const handleCreateNew = useCallback(
async (searchString?: string) => {
const updatePickerState = (
newRecordId: string,
targetObjectMetadataId: string,
) => {
const currentMorphItems = store.get(
multipleRecordPickerPickableMorphItemsCallbackState,
);
createNewRecordAndOpenSidePanel?.(searchString);
};
const newMorphItems = currentMorphItems.concat({
recordId: newRecordId,
objectMetadataId: targetObjectMetadataId,
isSelected: true,
isMatchingSearchFilter: true,
});
store.set(
multipleRecordPickerPickableMorphItemsCallbackState,
newMorphItems,
);
multipleRecordPickerPerformSearch({
multipleRecordPickerInstanceId: dropdownId,
forceSearchFilter: searchString,
forceSearchableObjectMetadataItems: [pickerObjectMetadataItem],
forcePickableMorphItems: newMorphItems,
});
};
if (
isJunctionRelation &&
isDefined(junctionConfig) &&
!isMorphJunction &&
isDefined(junctionTargetObjectMetadata)
) {
const { targetFields, sourceField } = junctionConfig;
const targetField = targetFields[0];
if (!isDefined(targetField) || !isDefined(sourceField)) {
return;
}
const sourceJoinColumnName = getSourceJoinColumnName({
sourceField,
sourceObjectMetadata: objectMetadataItem,
});
const targetJoinColumnName = getJoinColumnName(targetField.settings);
if (!sourceJoinColumnName || !targetJoinColumnName) {
return;
}
const newTargetId = v4();
const targetPayload = buildRecordLabelPayload({
id: newTargetId,
searchInput: searchString,
objectMetadataItem: junctionTargetObjectMetadata,
});
await createTargetRecord(targetPayload);
const newJunctionId = v4();
const createdJunction = await createJunctionRecord({
id: newJunctionId,
[sourceJoinColumnName]: recordId,
[targetJoinColumnName]: newTargetId,
});
if (isDefined(createdJunction)) {
store.set(
recordStoreFamilyState.atomFamily(recordId),
(currentRecord: ObjectRecord | null | undefined) => {
if (!isDefined(currentRecord)) {
return currentRecord;
}
const currentFieldValue = currentRecord[fieldName];
const updatedJunctionRecords = Array.isArray(currentFieldValue)
? [...currentFieldValue, createdJunction]
: [createdJunction];
return {
...currentRecord,
[fieldName]: updatedJunctionRecords,
} as ObjectRecord;
},
);
}
updatePickerState(newTargetId, junctionTargetObjectMetadata.id);
return;
}
closeDropdown(dropdownId);
createNewRecordAndOpenSidePanel?.(searchString);
},
[
closeDropdown,
createJunctionRecord,
createNewRecordAndOpenSidePanel,
createTargetRecord,
dropdownId,
fieldName,
isMorphJunction,
isJunctionRelation,
junctionConfig,
junctionTargetObjectMetadata,
multipleRecordPickerPickableMorphItemsCallbackState,
multipleRecordPickerPerformSearch,
objectMetadataItem,
pickerObjectMetadataItem,
recordId,
store,
],
);
const canCreateNew = !isMorphJunction;
const objectMetadataItemIdForCreate =
isJunctionRelation && isDefined(junctionTargetObjectMetadata)
? junctionTargetObjectMetadata.id
: relationObjectMetadataItem.id;
const handleChange = useCallback(
(morphItem: Parameters<typeof updateRelation>[0]) => {
if (isJunctionRelation && isJunctionConfigValid) {
updateJunctionRelationFromCell({ morphItem });
} else {
updateRelation(morphItem);
}
},
[
isJunctionRelation,
isJunctionConfigValid,
updateJunctionRelationFromCell,
updateRelation,
],
);
return (
<Dropdown
@@ -181,13 +399,9 @@ export const RecordDetailRelationSectionDropdownToMany = ({
<MultipleRecordPicker
focusId={dropdownId}
componentInstanceId={dropdownId}
onCreate={
isDefined(createNewRecordAndOpenSidePanel)
? handleCreateNew
: undefined
}
objectMetadataItemIdForCreate={relationObjectMetadataItem.id}
onChange={updateRelation}
onCreate={canCreateNew ? handleCreateNew : undefined}
objectMetadataItemIdForCreate={objectMetadataItemIdForCreate}
onChange={handleChange}
onSubmit={() => {
closeDropdown(dropdownId);
}}
@@ -22,6 +22,7 @@ type CategorizeRelationFieldsArgs = {
type CategorizedRelationFields = {
activityTargetFields: FieldMetadataItem[];
inlineRelationFields: FieldMetadataItem[];
junctionRelationFields: FieldMetadataItem[];
boxedRelationFields: FieldMetadataItem[];
};
@@ -70,6 +71,7 @@ export const categorizeRelationFields = ({
}: CategorizeRelationFieldsArgs): CategorizedRelationFields => {
const activityTargetFields: FieldMetadataItem[] = [];
const inlineRelationFields: FieldMetadataItem[] = [];
const junctionRelationFields: FieldMetadataItem[] = [];
const boxedRelationFields: FieldMetadataItem[] = [];
for (const field of relationFields) {
@@ -82,6 +84,7 @@ export const categorizeRelationFields = ({
// Junction relations (when feature enabled) are rendered inline with other fields
if (isJunctionRelationsEnabled && isJunctionRelationField(field)) {
inlineRelationFields.push(field);
junctionRelationFields.push(field);
continue;
}
@@ -94,6 +97,7 @@ export const categorizeRelationFields = ({
return {
activityTargetFields,
inlineRelationFields,
junctionRelationFields,
boxedRelationFields,
};
};
@@ -4,6 +4,7 @@ import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadat
import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition';
import { type FieldRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { hasJunctionConfig } from '@/object-record/record-field/ui/utils/junction/hasJunctionConfig';
import { useResolveFieldMetadataIdFromNameOrId } from '@/page-layout/hooks/useResolveFieldMetadataIdFromNameOrId';
import { isFieldWidget } from '@/page-layout/widgets/field/utils/isFieldWidget';
import { useCurrentWidget } from '@/page-layout/widgets/hooks/useCurrentWidget';
@@ -23,8 +24,8 @@ import {
TooltipPosition,
} from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { RelationType } from '~/generated-metadata/graphql';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { RelationType } from '~/generated-metadata/graphql';
const StyledLinkContainer = styled.div`
display: flex;
@@ -83,6 +84,8 @@ export const WidgetActionFieldSeeAll = () => {
const { objectMetadataItems } = useObjectMetadataItems();
const isJunction = hasJunctionConfig(relationMetadata?.settings);
const relationObjectMetadataItem = objectMetadataItems.find(
(item) =>
item.nameSingular ===
@@ -93,20 +96,26 @@ export const WidgetActionFieldSeeAll = () => {
({ id }) => id === relationMetadata?.relationFieldMetadataId,
);
const targetObjectMetadataItem = relationObjectMetadataItem;
const indexViewId = useAtomFamilySelectorValue(
indexViewIdFromObjectMetadataItemFamilySelector,
{ objectMetadataItemId: relationObjectMetadataItem?.id ?? '' },
{ objectMetadataItemId: targetObjectMetadataItem?.id ?? '' },
);
if (
!isDefined(relationMetadata) ||
relationMetadata.relationType !== RelationType.ONE_TO_MANY ||
!isDefined(relationFieldMetadataItem) ||
!isDefined(relationObjectMetadataItem)
!isDefined(targetObjectMetadataItem) ||
isJunction
) {
return null;
}
if (!isDefined(relationFieldMetadataItem)) {
return null;
}
const filterQueryParams = {
filter: {
[relationFieldMetadataItem.name]: {
@@ -121,14 +130,14 @@ export const WidgetActionFieldSeeAll = () => {
const filterLinkHref = getAppPath(
AppPath.RecordIndexPage,
{
objectNamePlural: relationObjectMetadataItem.namePlural,
objectNamePlural: targetObjectMetadataItem.namePlural,
},
filterQueryParams,
);
const tooltipId = `widget-see-all-${widget.id}`;
const relationLabelPlural =
relationObjectMetadataItem.labelPlural.toLowerCase();
targetObjectMetadataItem.labelPlural.toLowerCase();
const tooltipContent = t`See all ${relationLabelPlural} linked to this record`;
return (
@@ -4,10 +4,13 @@ import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/uti
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { isFieldRichText } from '@/object-record/record-field/ui/types/guards/isFieldRichText';
import { hasJunctionConfig } from '@/object-record/record-field/ui/utils/junction/hasJunctionConfig';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { useResolveFieldMetadataIdFromNameOrId } from '@/page-layout/hooks/useResolveFieldMetadataIdFromNameOrId';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { FieldWidgetDisplay } from '@/page-layout/widgets/field/components/FieldWidgetDisplay';
import { FieldWidgetJunctionRelationCard } from '@/page-layout/widgets/field/components/FieldWidgetJunctionRelationCard';
import { FieldWidgetJunctionRelationField } from '@/page-layout/widgets/field/components/FieldWidgetJunctionRelationField';
import { FieldWidgetRichTextEditor } from '@/page-layout/widgets/field/components/FieldWidgetRichTextEditor';
import { FieldWidgetMorphRelationCard } from '@/page-layout/widgets/field/components/FieldWidgetMorphRelationCard';
import { FieldWidgetMorphRelationField } from '@/page-layout/widgets/field/components/FieldWidgetMorphRelationField';
@@ -118,6 +121,32 @@ export const FieldWidget = ({ widget }: FieldWidgetProps) => {
}
if (isFieldRelation(fieldDefinition)) {
const isJunctionRelation = hasJunctionConfig(
fieldDefinition.metadata.settings,
);
if (isJunctionRelation) {
if (fieldDisplayMode === FieldDisplayMode.CARD) {
return (
<FieldWidgetJunctionRelationCard
fieldDefinition={fieldDefinition}
relationValue={record}
isInSidePanel={isInSidePanel}
sourceObjectMetadataId={objectMetadataItem.id}
/>
);
}
return (
<FieldWidgetJunctionRelationField
fieldDefinition={fieldDefinition}
relationValue={record}
isInSidePanel={isInSidePanel}
sourceObjectMetadataId={objectMetadataItem.id}
/>
);
}
if (fieldDisplayMode === FieldDisplayMode.CARD) {
return (
<FieldWidgetRelationCard
@@ -0,0 +1,203 @@
import { styled } from '@linaria/react';
import { Fragment, useState } from 'react';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { RecordFieldsScopeContextProvider } from '@/object-record/record-field-list/contexts/RecordFieldsScopeContext';
import { RecordDetailRecordsListContainer } from '@/object-record/record-field-list/record-detail-section/components/RecordDetailRecordsListContainer';
import { RecordDetailRelationRecordsListItem } from '@/object-record/record-field-list/record-detail-section/relation/components/RecordDetailRelationRecordsListItem';
import { RecordDetailRelationRecordsListItemEffect } from '@/object-record/record-field-list/record-detail-section/relation/components/RecordDetailRelationRecordsListItemEffect';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import {
FieldInputEventContext,
type FieldInputEvent,
} from '@/object-record/record-field/ui/contexts/FieldInputEventContext';
import { usePersistField } from '@/object-record/record-field/ui/hooks/usePersistField';
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import { type FieldRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { extractTargetRecordsFromJunction } from '@/object-record/record-field/ui/utils/junction/extractTargetRecordsFromJunction';
import { getJunctionConfig } from '@/object-record/record-field/ui/utils/junction/getJunctionConfig';
import { FieldWidgetShowMoreButton } from '@/page-layout/widgets/field/components/FieldWidgetShowMoreButton';
import { FIELD_WIDGET_RELATION_CARD_INITIAL_VISIBLE_ITEMS } from '@/page-layout/widgets/field/constants/FieldWidgetRelationCardInitialVisibleItems';
import { FIELD_WIDGET_RELATION_CARD_LOAD_MORE_INCREMENT } from '@/page-layout/widgets/field/constants/FieldWidgetRelationCardLoadMoreIncrement';
import { generateFieldWidgetInstanceId } from '@/page-layout/widgets/field/utils/generateFieldWidgetInstanceId';
import { useCurrentWidget } from '@/page-layout/widgets/hooks/useCurrentWidget';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { SidePanelProvider } from '@/ui/layout/side-panel/contexts/SidePanelContext';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledShowMoreButtonContainer = styled.div`
padding-top: ${themeCssVariables.spacing[2]};
`;
type FieldWidgetJunctionRelationCardProps = {
fieldDefinition: FieldDefinition<FieldRelationMetadata>;
relationValue: any;
isInSidePanel: boolean;
sourceObjectMetadataId: string;
};
export const FieldWidgetJunctionRelationCard = ({
fieldDefinition,
relationValue,
isInSidePanel,
sourceObjectMetadataId,
}: FieldWidgetJunctionRelationCardProps) => {
const widget = useCurrentWidget();
const [expandedItem, setExpandedItem] = useState('');
const [visibleItemsCount, setVisibleItemsCount] = useState(
FIELD_WIDGET_RELATION_CARD_INITIAL_VISIBLE_ITEMS,
);
const targetRecord = useTargetRecord();
const instanceId = generateFieldWidgetInstanceId({
widgetId: widget.id,
recordId: targetRecord.id,
fieldName: fieldDefinition.metadata.fieldName,
isInSidePanel,
});
const handleItemClick = (id: string) =>
setExpandedItem(id === expandedItem ? '' : id);
const handleShowMore = () => {
setVisibleItemsCount(
(prevCount) => prevCount + FIELD_WIDGET_RELATION_CARD_LOAD_MORE_INCREMENT,
);
};
const fieldMetadata = fieldDefinition.metadata;
const { objectMetadataItems } = useObjectMetadataItems();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: targetRecord.targetObjectNameSingular,
});
const { updateOneRecord } = useUpdateOneRecord();
const useUpdateOneObjectRecordMutation = () => {
const updateEntity = ({
variables,
}: {
variables: {
where: { id: string };
updateOneRecordInput: Record<string, unknown>;
};
}) => {
updateOneRecord({
objectNameSingular: targetRecord.targetObjectNameSingular,
idToUpdate: variables.where.id,
updateOneRecordInput: variables.updateOneRecordInput,
});
};
return [updateEntity, { loading: false }] as [
(params: any) => void,
{ loading: boolean },
];
};
const persistField = usePersistField({
objectMetadataItemId: objectMetadataItem.id,
});
const handleSubmit: FieldInputEvent = ({ newValue }) => {
persistField({
recordId: targetRecord.id,
fieldDefinition,
valueToPersist: newValue,
});
};
const junctionConfig = getJunctionConfig({
settings: fieldMetadata.settings,
relationObjectMetadataId: fieldMetadata.relationObjectMetadataId,
sourceObjectMetadataId,
objectMetadataItems,
});
if (!isDefined(junctionConfig)) {
return null;
}
const junctionRecords = Array.isArray(relationValue) ? relationValue : [];
const extractedRecords = extractTargetRecordsFromJunction({
junctionRecords,
targetFields: junctionConfig.targetFields,
objectMetadataItems,
includeRecord: true,
});
const targetRecordsWithMetadata = extractedRecords
.map((extracted) => {
const objectMetadata = objectMetadataItems.find(
(item) => item.id === extracted.objectMetadataId,
);
if (!objectMetadata || !extracted.record) {
return null;
}
return {
record: extracted.record,
objectNameSingular: objectMetadata.nameSingular,
};
})
.filter(isDefined);
if (targetRecordsWithMetadata.length === 0) {
return null;
}
const visibleRecords = targetRecordsWithMetadata.slice(0, visibleItemsCount);
const remainingCount = targetRecordsWithMetadata.length - visibleItemsCount;
const hasMoreRecords = remainingCount > 0;
return (
<SidePanelProvider value={{ isInSidePanel }}>
<RecordFieldsScopeContextProvider value={{ scopeInstanceId: instanceId }}>
<FieldContext.Provider
value={{
recordId: targetRecord.id,
isLabelIdentifier: false,
fieldDefinition,
useUpdateRecord: useUpdateOneObjectRecordMutation,
// Junction card detach/delete logic in RecordDetailRelationRecordsListItem
// assumes direct relations. Force read-only to prevent data corruption.
isRecordFieldReadOnly: true,
}}
>
<FieldInputEventContext.Provider value={{ onSubmit: handleSubmit }}>
<RecordDetailRecordsListContainer>
{visibleRecords.map((item) => (
<Fragment key={item.record.id}>
<RecordDetailRelationRecordsListItemEffect
relationRecordId={item.record.id}
relationObjectMetadataNameSingular={item.objectNameSingular}
/>
<RecordDetailRelationRecordsListItem
isExpanded={expandedItem === item.record.id}
onClick={handleItemClick}
relationRecord={item.record}
relationObjectMetadataNameSingular={item.objectNameSingular}
relationFieldMetadataId=""
/>
</Fragment>
))}
{hasMoreRecords && (
<StyledShowMoreButtonContainer>
<FieldWidgetShowMoreButton
remainingCount={remainingCount}
onClick={handleShowMore}
/>
</StyledShowMoreButtonContainer>
)}
</RecordDetailRecordsListContainer>
</FieldInputEventContext.Provider>
</FieldContext.Provider>
</RecordFieldsScopeContextProvider>
</SidePanelProvider>
);
};
@@ -0,0 +1,92 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { RecordChip } from '@/object-record/components/RecordChip';
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import { type FieldRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { extractTargetRecordsFromJunction } from '@/object-record/record-field/ui/utils/junction/extractTargetRecordsFromJunction';
import { getJunctionConfig } from '@/object-record/record-field/ui/utils/junction/getJunctionConfig';
import { SidePanelProvider } from '@/ui/layout/side-panel/contexts/SidePanelContext';
import { styled } from '@linaria/react';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledContainer = styled.div`
box-sizing: border-box;
padding: ${themeCssVariables.spacing[1]};
width: 100%;
`;
const StyledRelationChipsContainer = styled.div`
display: flex;
flex-wrap: wrap;
gap: ${themeCssVariables.spacing[1]};
`;
type FieldWidgetJunctionRelationFieldProps = {
fieldDefinition: FieldDefinition<FieldRelationMetadata>;
relationValue: any;
isInSidePanel: boolean;
sourceObjectMetadataId: string;
};
export const FieldWidgetJunctionRelationField = ({
fieldDefinition,
relationValue,
isInSidePanel,
sourceObjectMetadataId,
}: FieldWidgetJunctionRelationFieldProps) => {
const { objectMetadataItems } = useObjectMetadataItems();
const fieldMetadata = fieldDefinition.metadata;
const junctionConfig = getJunctionConfig({
settings: fieldMetadata.settings,
relationObjectMetadataId: fieldMetadata.relationObjectMetadataId,
sourceObjectMetadataId,
objectMetadataItems,
});
if (!isDefined(junctionConfig)) {
return null;
}
const junctionRecords = Array.isArray(relationValue) ? relationValue : [];
const extractedRecords = extractTargetRecordsFromJunction({
junctionRecords,
targetFields: junctionConfig.targetFields,
objectMetadataItems,
includeRecord: true,
});
const targetRecordsWithMetadata = extractedRecords
.map((extracted) => {
const objectMetadata = objectMetadataItems.find(
(item) => item.id === extracted.objectMetadataId,
);
if (!objectMetadata || !extracted.record) {
return null;
}
return { record: extracted.record, objectMetadata };
})
.filter(isDefined);
if (targetRecordsWithMetadata.length === 0) {
return null;
}
return (
<SidePanelProvider value={{ isInSidePanel }}>
<StyledContainer>
<StyledRelationChipsContainer>
{targetRecordsWithMetadata.map(({ record, objectMetadata }) => (
<RecordChip
key={record.id}
objectNameSingular={objectMetadata.nameSingular}
record={record}
/>
))}
</StyledRelationChipsContainer>
</StyledContainer>
</SidePanelProvider>
);
};
@@ -1,16 +1,30 @@
import { useFieldListFieldMetadataItems } from '@/object-record/record-field-list/hooks/useFieldListFieldMetadataItems';
import { isJunctionRelationField } from '@/object-record/record-field/ui/utils/junction/isJunctionRelationField';
import { FIELD_WIDGET_SUPPORTED_FIELD_TYPES } from '@/page-layout/widgets/field/constants/fieldWidgetSupportedFieldTypes';
import { useMemo } from 'react';
export const useFieldWidgetEligibleFields = (objectNameSingular: string) => {
const { boxedRelationFieldMetadataItems, inlineFieldMetadataItems } =
useFieldListFieldMetadataItems({ objectNameSingular });
const {
boxedRelationFieldMetadataItems,
junctionRelationFieldMetadataItems,
inlineFieldMetadataItems,
} = useFieldListFieldMetadataItems({ objectNameSingular });
return useMemo(() => {
const eligibleInlineFields = inlineFieldMetadataItems.filter((field) =>
FIELD_WIDGET_SUPPORTED_FIELD_TYPES.includes(field.type),
const eligibleInlineFields = inlineFieldMetadataItems.filter(
(field) =>
FIELD_WIDGET_SUPPORTED_FIELD_TYPES.includes(field.type) &&
!isJunctionRelationField(field),
);
return [...boxedRelationFieldMetadataItems, ...eligibleInlineFields];
}, [boxedRelationFieldMetadataItems, inlineFieldMetadataItems]);
return [
...boxedRelationFieldMetadataItems,
...junctionRelationFieldMetadataItems,
...eligibleInlineFields,
];
}, [
boxedRelationFieldMetadataItems,
junctionRelationFieldMetadataItems,
inlineFieldMetadataItems,
]);
};