feat(front): allow advanced relation fields in FieldWidget selector (#22005)

## After

<img width="706" height="760" alt="image"
src="https://github.com/user-attachments/assets/d118285f-baab-4187-988c-d0180d61a629"
/>
<img width="707" height="372" alt="image"
src="https://github.com/user-attachments/assets/5676d829-2ec1-494e-a8f0-5998f1f0c3c8"
/>


## Summary

The FieldWidget field-selection dropdown currently filters out relation
fields whose target is a system object, so users can't pick fields like
`calendarEventParticipants` on the CalendarEvent record page. The widget
itself can render them just fine as boxed relations — the restriction
only lives in the picker.

This unblocks the consistency story from #22003 (revert of #21857): once
shipped, participants can be added to the calendar event record page via
the existing FieldWidget mechanism instead of a bespoke side-panel page.

## Changes

- `isFieldCellSupported`: adds an opt-in `includeSystemObjectRelations`
option that skips the `isObjectMetadataAvailableForRelation` system
check.
- `useFieldListFieldMetadataItems`: forwards the option through to
`isFieldCellSupported`. Default is `false`, so all existing callers keep
current behavior.
- `useFieldWidgetEligibleFields`: turns the option on, so the
FieldWidget selector now surfaces fields like
`calendarEventParticipants`, `messageParticipants`, etc.

## Test plan

- [x] `nx typecheck twenty-front`
- [x] `nx lint:diff-with-main twenty-front`
- [ ] CI
- [ ] Manually verify the FieldWidget dropdown now lists
`calendarEventParticipants` on a CalendarEvent record page, and that
selecting it renders a participants list via the existing relation
card/field widget.

https://claude.ai/code/session_01RnMcjL35wdCRzpXN257RLJ

---
_Generated by [Claude
Code](https://claude.ai/code/session_01RnMcjL35wdCRzpXN257RLJ)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22005?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
martmull
2026-06-24 10:30:42 +02:00
committed by GitHub
parent f98f514640
commit 269c8ef400
8 changed files with 135 additions and 26 deletions
@@ -14,6 +14,7 @@ type UseFieldListFieldMetadataItemsProps = {
excludeFieldMetadataIds?: string[];
excludeCreatedAtAndUpdatedAt?: boolean;
showRelationSections?: boolean;
includeSystemObjectRelations?: boolean;
};
export const useFieldListFieldMetadataItems = ({
@@ -21,6 +22,7 @@ export const useFieldListFieldMetadataItems = ({
excludeFieldMetadataIds = [],
showRelationSections = true,
excludeCreatedAtAndUpdatedAt = true,
includeSystemObjectRelations = false,
}: UseFieldListFieldMetadataItemsProps) => {
const { labelIdentifierFieldMetadataItem } =
useLabelIdentifierFieldMetadataItem({
@@ -42,7 +44,9 @@ export const useFieldListFieldMetadataItems = ({
const availableFieldMetadataItems = objectMetadataItem.readableFields
.filter(
(fieldMetadataItem) =>
isFieldCellSupported(fieldMetadataItem, objectMetadataItems) &&
isFieldCellSupported(fieldMetadataItem, objectMetadataItems, {
includeSystemObjectRelations,
}) &&
fieldMetadataItem.id !== labelIdentifierFieldMetadataItem?.id &&
!excludeFieldMetadataIds.includes(fieldMetadataItem.id) &&
(!excludeCreatedAtAndUpdatedAt ||
@@ -0,0 +1,12 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isFieldCellSupported } from '@/object-record/utils/isFieldCellSupported';
export const isAdvancedRelationFieldMetadataItem = (
fieldMetadataItem: FieldMetadataItem,
objectMetadataItems: EnrichedObjectMetadataItem[],
) =>
!isFieldCellSupported(fieldMetadataItem, objectMetadataItems) &&
isFieldCellSupported(fieldMetadataItem, objectMetadataItems, {
includeSystemObjectRelations: true,
});
@@ -5,9 +5,14 @@ import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField
import { isObjectMetadataAvailableForRelation } from '@/object-metadata/utils/isObjectMetadataAvailableForRelation';
import { FieldMetadataType } from '~/generated-metadata/graphql';
type IsFieldCellSupportedOptions = {
includeSystemObjectRelations?: boolean;
};
export const isFieldCellSupported = (
fieldMetadataItem: FieldMetadataItem,
objectMetadataItems: EnrichedObjectMetadataItem[],
options: IsFieldCellSupportedOptions = {},
) => {
if (fieldMetadataItem.type === FieldMetadataType.POSITION) {
return false;
@@ -40,9 +45,12 @@ export const isFieldCellSupported = (
return true;
}
if (!fieldMetadataItem.relation || !relationObjectMetadataItem) {
return false;
}
if (
!fieldMetadataItem.relation ||
!relationObjectMetadataItem ||
!options.includeSystemObjectRelations &&
!isObjectMetadataAvailableForRelation(relationObjectMetadataItem)
) {
return false;
@@ -7,7 +7,12 @@ export const useFieldWidgetEligibleFields = (objectNameSingular: string) => {
boxedRelationFieldMetadataItems,
junctionRelationFieldMetadataItems,
inlineFieldMetadataItems,
} = useFieldListFieldMetadataItems({ objectNameSingular });
} = useFieldListFieldMetadataItems({
objectNameSingular,
// Allow advanced relation fields targeting system objects (e.g. calendarEventParticipants)
// to appear in the FieldWidget selector — the widget can render them as boxed relations.
includeSystemObjectRelations: true,
});
return useMemo(() => {
const eligibleInlineFields = inlineFieldMetadataItems.filter(
@@ -1,4 +1,6 @@
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { isAdvancedRelationFieldMetadataItem } from '@/object-record/utils/isAdvancedRelationFieldMetadataItem';
import { isDefined } from 'twenty-shared/utils';
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
import { useFieldWidgetEligibleFields } from '@/page-layout/widgets/field/hooks/useFieldWidgetEligibleFields';
@@ -9,6 +11,8 @@ import {
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { useUpdateCurrentWidgetConfig } from '@/side-panel/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
import { DropdownAdvancedSectionHeader } from '@/ui/layout/dropdown/components/DropdownAdvancedSectionHeader';
import { DropdownAdvancedSectionMenuItem } from '@/ui/layout/dropdown/components/DropdownAdvancedSectionMenuItem';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
@@ -20,7 +24,8 @@ import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { isNonEmptyString } from '@sniptt/guards';
import { useMemo, useState } from 'react';
import { useIcons } from 'twenty-ui/icon';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { type FieldConfiguration } from '~/generated-metadata/graphql';
@@ -28,6 +33,7 @@ import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
export const FieldWidgetFieldDropdownContent = () => {
const [searchQuery, setSearchQuery] = useState('');
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const { pageLayoutId, objectNameSingular } =
usePageLayoutIdFromContextStore();
@@ -43,6 +49,31 @@ export const FieldWidgetFieldDropdownContent = () => {
const allFieldWidgetFieldMetadataItems =
useFieldWidgetEligibleFields(objectNameSingular);
const { objectMetadataItems } = useObjectMetadataItems();
const advancedFieldMetadataItems = useMemo(
() =>
allFieldWidgetFieldMetadataItems.filter((fieldMetadataItem) =>
isAdvancedRelationFieldMetadataItem(
fieldMetadataItem,
objectMetadataItems,
),
),
[allFieldWidgetFieldMetadataItems, objectMetadataItems],
);
const regularFieldMetadataItems = useMemo(
() =>
allFieldWidgetFieldMetadataItems.filter(
(fieldMetadataItem) =>
!isAdvancedRelationFieldMetadataItem(
fieldMetadataItem,
objectMetadataItems,
),
),
[allFieldWidgetFieldMetadataItems, objectMetadataItems],
);
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
@@ -62,11 +93,29 @@ export const FieldWidgetFieldDropdownContent = () => {
const { getIcon } = useIcons();
const availableFields = filterBySearchQuery({
items: allFieldWidgetFieldMetadataItems,
items: isAdvancedOpen
? advancedFieldMetadataItems
: regularFieldMetadataItems,
searchQuery,
getSearchableValues: (item) => [item.label],
});
const shouldShowAdvanced =
!isAdvancedOpen &&
advancedFieldMetadataItems.length > 0 &&
(!isNonEmptyString(searchQuery) ||
searchQuery.toLowerCase().includes('advanced'));
const handleOpenAdvanced = () => {
setIsAdvancedOpen(true);
setSearchQuery('');
};
const handleBackFromAdvanced = () => {
setIsAdvancedOpen(false);
setSearchQuery('');
};
const { fieldMetadataItem: currentFieldMetadataItem } =
useFieldMetadataItemById(currentFieldMetadataId ?? '');
@@ -108,6 +157,9 @@ export const FieldWidgetFieldDropdownContent = () => {
return (
<>
{isAdvancedOpen && (
<DropdownAdvancedSectionHeader onBack={handleBackFromAdvanced} />
)}
<DropdownMenuSearchInput
autoFocus
type="text"
@@ -146,6 +198,9 @@ export const FieldWidgetFieldDropdownContent = () => {
</SelectableListItem>
))}
</SelectableList>
{shouldShowAdvanced && (
<DropdownAdvancedSectionMenuItem onClick={handleOpenAdvanced} />
)}
</DropdownMenuItemsContainer>
</>
);
@@ -0,0 +1,23 @@
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { Trans } from '@lingui/react/macro';
import { IconChevronLeft } from 'twenty-ui/icon';
type DropdownAdvancedSectionHeaderProps = {
onBack: () => void;
};
export const DropdownAdvancedSectionHeader = ({
onBack,
}: DropdownAdvancedSectionHeaderProps) => (
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={onBack}
Icon={IconChevronLeft}
/>
}
>
<Trans>Advanced</Trans>
</DropdownMenuHeader>
);
@@ -0,0 +1,18 @@
import { Trans } from '@lingui/react/macro';
import { IconSettings } from 'twenty-ui/icon';
import { MenuItem } from 'twenty-ui/navigation';
type DropdownAdvancedSectionMenuItemProps = {
onClick: () => void;
};
export const DropdownAdvancedSectionMenuItem = ({
onClick,
}: DropdownAdvancedSectionMenuItemProps) => (
<MenuItem
text={<Trans>Advanced</Trans>}
LeftIcon={IconSettings}
onClick={onClick}
hasSubMenu
/>
);
@@ -1,16 +1,14 @@
import { ObjectMetadataIcon } from '@/object-metadata/components/ObjectMetadataIcon';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { DropdownAdvancedSectionHeader } from '@/ui/layout/dropdown/components/DropdownAdvancedSectionHeader';
import { DropdownAdvancedSectionMenuItem } from '@/ui/layout/dropdown/components/DropdownAdvancedSectionMenuItem';
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 { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { Trans } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { IconChevronLeft, IconSettings } from 'twenty-ui/icon';
import { MenuItem } from 'twenty-ui/navigation';
type WorkflowObjectDropdownContentProps = {
@@ -99,16 +97,7 @@ export const WorkflowObjectDropdownContent = ({
return (
<DropdownContent widthInPixels={GenericDropdownContentWidth.ExtraLarge}>
{isSystemObjectsOpen && (
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={handleBack}
Icon={IconChevronLeft}
/>
}
>
<Trans>Advanced</Trans>
</DropdownMenuHeader>
<DropdownAdvancedSectionHeader onBack={handleBack} />
)}
<DropdownMenuSearchInput
autoFocus
@@ -128,12 +117,7 @@ export const WorkflowObjectDropdownContent = ({
/>
))}
{shouldShowAdvanced && (
<MenuItem
text={<Trans>Advanced</Trans>}
LeftIcon={IconSettings}
onClick={handleAdvancedClick}
hasSubMenu
/>
<DropdownAdvancedSectionMenuItem onClick={handleAdvancedClick} />
)}
</DropdownMenuItemsContainer>
</DropdownContent>