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:
+5
-1
@@ -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 ||
|
||||
|
||||
+12
@@ -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;
|
||||
|
||||
+6
-1
@@ -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(
|
||||
|
||||
+57
-2
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
+23
@@ -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>
|
||||
);
|
||||
+18
@@ -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
|
||||
/>
|
||||
);
|
||||
+4
-20
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user