Add nested relation Field widgets on record page layouts (#23815)

## Context

Record pages can show a list of directly related records (Field widget
in Table display mode), but not records two relation hops away. The
canonical ask: on a Client page, list the Transactions of the
Client's Wallets.

Stacked on #23814 (merged) and #23832 (merged); their commits are
included in this branch. #23836 stacks on this PR to add many-to-one
first hops.

## How it works

The 2-hop case does not need any new query capability. It reuses the
relation traversal filter shipped for advanced filters: the widget
embeds a view on the terminal object (Transaction) with one seeded
filter `inverse relation IS current record`, traversed one hop
(`fieldMetadataId` = Transaction.wallet, `relationTargetFieldMetadataId`
= Wallet.client, value `isCurrentRecordSelected`). At query time this
compiles to `{ wallet: { clientId: { in: [currentRecordId] } } }`, which
is within the backend's `MAX_RELATION_FILTER_DEPTH = 1` since the
second hop lands on the join column. Records from all intermediate
records (all wallets of the client) are listed, so one-to-many fan-out
on the first hop works out of the box.

## Changes

Configuration
- `FieldConfiguration` gains an optional `nestedRelationFieldMetadataId`
(shared type, DTO, GraphQL fragment, regenerated metadata types).
Backward compatible: existing widgets are untouched.

UI
- The Field picker drills into one-to-many relation fields, mirroring
the advanced filter submenu pattern: back header, an entry to select the
relation itself (previous behavior), then the target object's
one-to-many relations. Selecting a nested field creates a widget titled
`First hop → Second hop` in Table display mode. First-level rows that
open a submenu never show the checkmark; the selected chain is only
visible inside the submenu, matching the chart group by field selection.
- The layout dropdown, settings panel and renderer resolve the terminal
object of the chain; a widget whose second hop was deleted or
deactivated renders nothing instead of silently showing first-hop
records.
- Nested widgets only offer embedded view layouts (Table / Kanban /
Calendar), since inline display modes would render the first hop's
relation field.
- The relation table view resolver regenerates the embedded view
whenever the selection results in a table widget and the chain changed
or the view id is missing, so a table widget can never carry a view
belonging to a different chain.

Server
- `FieldConfigurationDTO` accepts the new optional field.
- Both universal configuration mappers (to and from universal
identifiers) carry it for app manifest sync.
- New `validateFieldConfigurationNestedRelationOrThrow` enforces that
both hops are active one-to-many relation fields on the right objects,
wired next to the existing chart field reference validation.

Record creation
- `buildRecordInputFromFilter` skips relation-traversal filters: they
constrain a related record's column, so prefilling the created
record's own foreign key from them would link the wrong record (e.g.
`walletId = clientId`).
- Add New in a nested widget table instead prompts for the record to
create through: the row opens a picker listing the current record's
first-hop records (the client's wallets), scoped with a find filter
on the relation join column, and creates the record with the picked id
prefilled. Covers the plain table and per-group add rows. Board and
calendar layouts hide their create buttons in nested widgets since they
cannot know the record to create through.
- Matching the created record against the widget's traversal filter
client side is handled by #23832.

Out of scope, deliberately: depth stays at exactly two levels (matches
the backend filter depth cap), junction and morph relations are not
drillable, and chart widgets on record pages are untouched.

## Tests

- Unit: nested chain resolution util, draft view seeding with the
traversal filter, view id change resolver, picker parameter derivation,
server-side validation. Full `page-layout` and `record-filter` front
suites pass (187 suites / 1233 tests), server `page-layout-widget`
suites pass.
- Manual, on seeded data: created a `People → Opportunities` widget on a
Company page; it lists exactly the opportunities whose point of contact
belongs to that company, persists across save and reload, and scopes per
record. Add New opens a picker showing only that company's people;
picking one creates an opportunity with `pointOfContactId` set (verified
in DB) and the row appears in the widget immediately.

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

<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23815?utm_source=github"
rel="nofollow noreferrer noopener" target="_blank">``&lt;img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;``</a>
This commit is contained in:
Félix Malfait
2026-08-06 09:26:53 +02:00
committed by GitHub
parent 9480513689
commit 647a6aec58
54 changed files with 2329 additions and 176 deletions
@@ -1311,6 +1311,7 @@ type FieldConfiguration {
fieldMetadataId: String!
fieldDisplayMode: FieldDisplayMode!
viewId: String
nestedRelationFieldMetadataId: String
}
"""Display mode for field configuration widgets"""
@@ -966,6 +966,7 @@ export interface FieldConfiguration {
fieldMetadataId: Scalars['String']
fieldDisplayMode: FieldDisplayMode
viewId?: Scalars['String']
nestedRelationFieldMetadataId?: Scalars['String']
__typename: 'FieldConfiguration'
}
@@ -4191,6 +4192,7 @@ export interface FieldConfigurationGenqlSelection{
fieldMetadataId?: boolean | number
fieldDisplayMode?: boolean | number
viewId?: boolean | number
nestedRelationFieldMetadataId?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -2531,6 +2531,9 @@ export default {
"viewId": [
1
],
"nestedRelationFieldMetadataId": [
1
],
"__typename": [
1
]
@@ -58,6 +58,28 @@ export default definePageLayout({
- Each `widget` inside a tab can render a [front component](/developers/extend/apps/layout/front-components), a relation list, or other built-in widget types.
- `position` on tabs controls their order. Use higher values (e.g., 50) to place custom tabs after built-in ones.
### Field widgets
A `FIELD` widget renders one field of the record. For relation fields it can also embed a list of related records:
```ts
{
universalIdentifier: 'c1c2c3c4-c5c6-4000-8000-000000000003',
title: 'People → Opportunities',
type: 'FIELD',
configuration: {
configurationType: 'FIELD',
fieldMetadataId: PEOPLE_FIELD_UNIVERSAL_IDENTIFIER,
fieldDisplayMode: 'TABLE',
nestedRelationFieldMetadataId: OPPORTUNITIES_FIELD_UNIVERSAL_IDENTIFIER,
},
}
```
- `fieldMetadataId` takes the universal identifier of a field on the layout's object.
- `fieldDisplayMode` is one of `'FIELD'`, `'CARD'`, `'EDITOR'`, `'VIEW'` or `'TABLE'`. `TABLE` embeds a view listing the records of a one-to-many relation field.
- `nestedRelationFieldMetadataId` is optional and takes the universal identifier of a one-to-many relation field on the relation target object, to list records two relation hops away (e.g. a Company page listing the opportunities of the company's people, or a Person page listing the opportunities of the person's company). The first hop can be a one-to-many or a many-to-one relation field, the second must be one-to-many (junction relations are not supported), and it requires `fieldDisplayMode: 'TABLE'` — combining it with any other display mode is a validation error, since a nested widget always renders as an embedded view.
## definePageLayoutTab
Use this when you only want to **add** a tab to an existing layout — for example, an analytics tab on the standard Company page, or an AI summary tab attached to your own object's layout.
@@ -293,6 +293,9 @@ projection. The generation pipeline reads that Markdown projection, so
placeholders, the PDF, and the shareable web page all keep working unchanged —
see the full
[`template-record.page-layout.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/page-layouts/template-record.page-layout.ts).
`FIELD` widgets support other display modes and options too, including
listing records of a nested relation two hops away — see the
[field widget reference](/developers/extend/apps/layout/page-layouts#field-widgets).
Now editors write templates in a proper rich-text editor:
<Frame caption="The Template tab: Twenty's native rich-text editor bound to the body field.">
File diff suppressed because one or more lines are too long
@@ -1,12 +1,10 @@
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
type FieldWithRelation = {
type: FieldMetadataType;
relation?: { type: RelationType } | null;
};
import {
type FieldWithRelation,
isRelationFieldOfType,
} from '@/object-metadata/utils/isRelationFieldOfType';
import { RelationType } from '~/generated-metadata/graphql';
export const isManyToOneRelationField = <T extends FieldWithRelation>(
field: T,
): field is T & { relation: NonNullable<T['relation']> } =>
field.type === FieldMetadataType.RELATION &&
field.relation?.type === RelationType.MANY_TO_ONE;
isRelationFieldOfType(field, RelationType.MANY_TO_ONE);
@@ -0,0 +1,10 @@
import {
type FieldWithRelation,
isRelationFieldOfType,
} from '@/object-metadata/utils/isRelationFieldOfType';
import { RelationType } from '~/generated-metadata/graphql';
export const isOneToManyRelationField = <T extends FieldWithRelation>(
field: T,
): field is T & { relation: NonNullable<T['relation']> } =>
isRelationFieldOfType(field, RelationType.ONE_TO_MANY);
@@ -0,0 +1,16 @@
import {
FieldMetadataType,
type RelationType,
} from '~/generated-metadata/graphql';
export type FieldWithRelation = {
type: FieldMetadataType;
relation?: { type: RelationType } | null;
};
export const isRelationFieldOfType = <T extends FieldWithRelation>(
field: T,
relationType: RelationType,
): field is T & { relation: NonNullable<T['relation']> } =>
field.type === FieldMetadataType.RELATION &&
field.relation?.type === relationType;
@@ -18,6 +18,7 @@ import { getFieldMetadataItemGqlFieldName } from '@/object-metadata/utils/getFie
import { recordIndexAggregateDisplayLabelComponentState } from '@/object-record/record-index/states/recordIndexAggregateDisplayLabelComponentState';
import { recordIndexAggregateDisplayValueForGroupValueComponentFamilyState } from '@/object-record/record-index/states/recordIndexAggregateDisplayValueForGroupValueComponentFamilyState';
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
import { RecordTableWidgetContext } from '@/object-record/record-table-widget/contexts/RecordTableWidgetContext';
import { isRecordBoardViewSettingsReadOnlyComponentState } from '@/object-record/record-board/states/isRecordBoardViewSettingsReadOnlyComponentState';
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
@@ -27,6 +28,7 @@ import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDrop
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { isDefined } from 'twenty-shared/utils';
import { IconDotsVertical, IconPlus } from 'twenty-ui/icon';
import { LightIconButton } from 'twenty-ui/input';
@@ -144,10 +146,18 @@ export const RecordBoardColumnHeader = () => {
objectMetadataItem.id,
);
const canCreateRecords = canCreateRecordsForObjectMetadataItem({
objectPermissions,
objectMetadataItem,
});
// Creating in a nested relation widget requires picking the related record
// to create through, which only the table layout offers today.
const nestedRelationCreateThrough = useContext(
RecordTableWidgetContext,
)?.nestedRelationCreateThrough;
const canCreateRecords =
!isDefined(nestedRelationCreateThrough) &&
canCreateRecordsForObjectMetadataItem({
objectPermissions,
objectMetadataItem,
});
const hasAnySoftDeleteFilterOnView = useAtomComponentSelectorValue(
hasAnySoftDeleteFilterOnViewComponentSelector,
@@ -4,11 +4,13 @@ import { RecordBoardColumnContext } from '@/object-record/record-board/record-bo
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
import { getFieldMetadataItemGqlFieldName } from '@/object-metadata/utils/getFieldMetadataItemGqlFieldName';
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
import { RecordTableWidgetContext } from '@/object-record/record-table-widget/contexts/RecordTableWidgetContext';
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconPlus } from 'twenty-ui/icon';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
@@ -48,6 +50,16 @@ export const RecordBoardColumnNewRecordButton = () => {
objectMetadataItem: objectMetadataItem,
});
// Creating in a nested relation widget requires picking the related record
// to create through, which only the table layout offers today.
const nestedRelationCreateThrough = useContext(
RecordTableWidgetContext,
)?.nestedRelationCreateThrough;
if (isDefined(nestedRelationCreateThrough)) {
return null;
}
if (
!canCreateRecordsForObjectMetadataItem({
objectPermissions,
@@ -7,6 +7,7 @@ import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/r
import { recordIndexCalendarEndFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdComponentState';
import { recordIndexCalendarFieldMetadataIdComponentState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdComponentState';
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
import { RecordTableWidgetContext } from '@/object-record/record-table-widget/contexts/RecordTableWidgetContext';
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
@@ -15,6 +16,7 @@ import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { type Temporal } from 'temporal-polyfill';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconPlus } from 'twenty-ui/icon';
import { Button } from 'twenty-ui/input';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
@@ -85,7 +87,14 @@ export const RecordCalendarAddNew = ({
})
: false;
// Creating in a nested relation widget requires picking the related record
// to create through, which only the table layout offers today.
const nestedRelationCreateThrough = useContext(
RecordTableWidgetContext,
)?.nestedRelationCreateThrough;
if (
isDefined(nestedRelationCreateThrough) ||
isRecordCalendarReadOnly ||
hasAnySoftDeleteFilterOnView === true ||
!canCreateRecordsForObjectMetadataItem({
@@ -0,0 +1,43 @@
import { RecordTableActionRow } from '@/object-record/record-table/record-table-row/components/RecordTableActionRow';
import { RecordTableWidgetNestedRelationPickerDropdownContent } from '@/object-record/record-table-widget/components/RecordTableWidgetNestedRelationPickerDropdownContent';
import { type RecordTableWidgetNestedRelationCreateThrough } from '@/object-record/record-table-widget/contexts/RecordTableWidgetContext';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { t } from '@lingui/core/macro';
import { IconPlus } from 'twenty-ui/icon';
type RecordTableWidgetNestedRelationAddNewRowProps = {
dropdownId: string;
nestedRelationCreateThrough: RecordTableWidgetNestedRelationCreateThrough;
onRelationRecordSelected: (relationRecordId: string) => void;
};
export const RecordTableWidgetNestedRelationAddNewRow = ({
dropdownId,
nestedRelationCreateThrough,
onRelationRecordSelected,
}: RecordTableWidgetNestedRelationAddNewRowProps) => {
const { closeDropdown } = useCloseDropdown();
const handleRelationRecordSelected = (relationRecordId: string) => {
closeDropdown(dropdownId);
onRelationRecordSelected(relationRecordId);
};
return (
<Dropdown
dropdownId={dropdownId}
dropdownPlacement="bottom-start"
clickableComponentWidth="100%"
clickableComponent={
<RecordTableActionRow LeftIcon={IconPlus} text={t`Add New`} />
}
dropdownComponents={
<RecordTableWidgetNestedRelationPickerDropdownContent
nestedRelationCreateThrough={nestedRelationCreateThrough}
onRelationRecordSelected={handleRelationRecordSelected}
/>
}
/>
);
};
@@ -0,0 +1,78 @@
import { RecordPickerLoadingSkeletonList } from '@/object-record/record-picker/components/RecordPickerLoadingSkeletonList';
import { RecordPickerNoRecordFoundMenuItem } from '@/object-record/record-picker/components/RecordPickerNoRecordFoundMenuItem';
import { RecordTableWidgetNestedRelationPickerMenuItem } from '@/object-record/record-table-widget/components/RecordTableWidgetNestedRelationPickerMenuItem';
import { type RecordTableWidgetNestedRelationCreateThrough } from '@/object-record/record-table-widget/contexts/RecordTableWidgetContext';
import { useRecordsForSelect } from '@/object-record/select/hooks/useRecordsForSelect';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
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 { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
type RecordTableWidgetNestedRelationPickerDropdownContentProps = {
nestedRelationCreateThrough: RecordTableWidgetNestedRelationCreateThrough;
onRelationRecordSelected: (relationRecordId: string) => void;
};
export const RecordTableWidgetNestedRelationPickerDropdownContent = ({
nestedRelationCreateThrough,
onRelationRecordSelected,
}: RecordTableWidgetNestedRelationPickerDropdownContentProps) => {
const [searchFilter, setSearchFilter] = useState('');
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
const { recordsToSelect, loading } = useRecordsForSelect({
searchFilterText: searchFilter,
selectedIds: [],
objectNameSingular:
nestedRelationCreateThrough.relationObjectMetadataNameSingular,
allowRequestsToTwentyIcons: true,
filter: nestedRelationCreateThrough.relationRecordsFilter,
});
return (
<DropdownContent>
<DropdownMenuSearchInput
autoFocus
type="text"
placeholder={t`Search`}
value={searchFilter}
onChange={(event) => setSearchFilter(event.target.value)}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={recordsToSelect.map(
(relationRecord) => relationRecord.id,
)}
>
{loading && recordsToSelect.length === 0 ? (
<RecordPickerLoadingSkeletonList />
) : (
<>
{recordsToSelect.map((relationRecord) => (
<RecordTableWidgetNestedRelationPickerMenuItem
key={relationRecord.id}
relationRecord={relationRecord}
onSelect={onRelationRecordSelected}
/>
))}
{recordsToSelect.length === 0 && (
<RecordPickerNoRecordFoundMenuItem />
)}
</>
)}
</SelectableList>
</DropdownMenuItemsContainer>
</DropdownContent>
);
};
@@ -0,0 +1,52 @@
import { type SelectableItem } from '@/object-record/select/types/SelectableItem';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { isSelectedItemIdComponentFamilyState } from '@/ui/layout/selectable-list/states/isSelectedItemIdComponentFamilyState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue';
import { Avatar } from 'twenty-ui/data-display';
import { MenuItemSelectAvatar } from 'twenty-ui/navigation';
import { getAbsoluteImageUrl } from '~/utils/image/getAbsoluteImageUrl';
type RecordTableWidgetNestedRelationPickerMenuItemProps = {
relationRecord: SelectableItem;
onSelect: (relationRecordId: string) => void;
};
export const RecordTableWidgetNestedRelationPickerMenuItem = ({
relationRecord,
onSelect,
}: RecordTableWidgetNestedRelationPickerMenuItemProps) => {
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
const isSelectedItemId = useAtomComponentFamilyStateValue(
isSelectedItemIdComponentFamilyState,
relationRecord.id,
dropdownId,
);
return (
<SelectableListItem
itemId={relationRecord.id}
onEnter={() => onSelect(relationRecord.id)}
>
<MenuItemSelectAvatar
onClick={() => onSelect(relationRecord.id)}
text={relationRecord.name}
selected={false}
focused={isSelectedItemId}
avatar={
<Avatar
avatarUrl={getAbsoluteImageUrl(relationRecord.avatarUrl)}
placeholderColorSeed={relationRecord.id}
placeholder={relationRecord.name}
size="md"
type={relationRecord.avatarType ?? 'rounded'}
/>
}
/>
</SelectableListItem>
);
};
@@ -8,7 +8,10 @@ import { RecordIndexContextProvider } from '@/object-record/record-index/context
import { useRecordIndexFieldMetadataDerivedStates } from '@/object-record/record-index/hooks/useRecordIndexFieldMetadataDerivedStates';
import { RecordTableWidgetContextStoreInitEffect } from '@/object-record/record-table-widget/components/RecordTableWidgetContextStoreInitEffect';
import { RecordTableWidgetViewLoadEffect } from '@/object-record/record-table-widget/components/RecordTableWidgetViewLoadEffect';
import { RecordTableWidgetContext } from '@/object-record/record-table-widget/contexts/RecordTableWidgetContext';
import {
RecordTableWidgetContext,
type RecordTableWidgetNestedRelationCreateThrough,
} from '@/object-record/record-table-widget/contexts/RecordTableWidgetContext';
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { useComponentInstanceStateContext } from '@/ui/utilities/state/component-state/hooks/useComponentInstanceStateContext';
@@ -26,6 +29,7 @@ type RecordTableWidgetProviderProps = PropsWithChildren<{
recordLimit?: number;
instanceIdSuffix?: string;
contextStoreViewType?: ContextStoreViewType;
nestedRelationCreateThrough?: RecordTableWidgetNestedRelationCreateThrough;
}>;
export const RecordTableWidgetProvider = ({
@@ -35,6 +39,7 @@ export const RecordTableWidgetProvider = ({
recordLimit,
instanceIdSuffix,
contextStoreViewType,
nestedRelationCreateThrough,
children,
}: RecordTableWidgetProviderProps) => {
const { objectMetadataItem } = useObjectMetadataItem({
@@ -90,11 +95,13 @@ export const RecordTableWidgetProvider = ({
isPageLayoutInEditMode,
pageLayoutId: pageLayoutComponentInstanceContext?.instanceId,
widgetId,
nestedRelationCreateThrough,
}),
[
isPageLayoutInEditMode,
pageLayoutComponentInstanceContext?.instanceId,
widgetId,
nestedRelationCreateThrough,
],
);
@@ -1,9 +1,22 @@
import { type RecordGqlOperationFilter } from 'twenty-shared/types';
import { createContext } from 'react';
// Creating a record in a nested relation widget requires picking the related
// record to create through: the created record's join column has to point at
// a record of the widget's first hop (e.g. picking one of the company's
// people before creating an opportunity on a Company → People → Opportunities
// widget).
export type RecordTableWidgetNestedRelationCreateThrough = {
relationObjectMetadataNameSingular: string;
relationRecordsFilter: RecordGqlOperationFilter;
nestedRelationJoinColumnName: string;
};
export type RecordTableWidgetContextValue = {
isPageLayoutInEditMode: boolean;
pageLayoutId?: string;
widgetId: string;
nestedRelationCreateThrough?: RecordTableWidgetNestedRelationCreateThrough;
};
export const RecordTableWidgetContext =
@@ -5,18 +5,25 @@ import { useRecordTableContextOrThrow } from '@/object-record/record-table/conte
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
import { isRecordTableCellsNonEditableComponentState } from '@/object-record/record-table/states/isRecordTableCellsNonEditableComponentState';
import { RecordTableActionRow } from '@/object-record/record-table/record-table-row/components/RecordTableActionRow';
import { RecordTableWidgetNestedRelationAddNewRow } from '@/object-record/record-table-widget/components/RecordTableWidgetNestedRelationAddNewRow';
import { RecordTableWidgetContext } from '@/object-record/record-table-widget/contexts/RecordTableWidgetContext';
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
import { useLoadRecordsToVirtualRows } from '@/object-record/record-table/virtualization/hooks/useLoadRecordsToVirtualRows';
import { totalNumberOfRecordsToVirtualizeComponentState } from '@/object-record/record-table/virtualization/states/totalNumberOfRecordsToVirtualizeComponentState';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { t } from '@lingui/core/macro';
import { useCallback } from 'react';
import { useCallback, useContext } from 'react';
import { type ObjectRecord } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconPlus } from 'twenty-ui/icon';
export const RecordTableNoRecordGroupAddNew = () => {
const { objectMetadataItem } = useRecordTableContextOrThrow();
const { objectMetadataItem, recordTableId } = useRecordTableContextOrThrow();
const nestedRelationCreateThrough = useContext(
RecordTableWidgetContext,
)?.nestedRelationCreateThrough;
const isRecordTableCellsNonEditable = useAtomComponentStateValue(
isRecordTableCellsNonEditableComponentState,
@@ -41,25 +48,29 @@ export const RecordTableNoRecordGroupAddNew = () => {
const { loadRecordsToVirtualRows } = useLoadRecordsToVirtualRows();
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const handleButtonClick = useCallback(async () => {
const createdRecord = await createNewIndexRecord({
position: 'last',
});
upsertRecordsInStore({ partialRecords: [createdRecord] });
if (isDefined(totalNumberOfRecordsToVirtualize)) {
loadRecordsToVirtualRows({
records: [createdRecord],
startingRealIndex: totalNumberOfRecordsToVirtualize,
const handleCreateRecord = useCallback(
async (recordInput?: Partial<ObjectRecord>) => {
const createdRecord = await createNewIndexRecord({
position: 'last',
...recordInput,
});
}
}, [
createNewIndexRecord,
upsertRecordsInStore,
loadRecordsToVirtualRows,
totalNumberOfRecordsToVirtualize,
]);
upsertRecordsInStore({ partialRecords: [createdRecord] });
if (isDefined(totalNumberOfRecordsToVirtualize)) {
loadRecordsToVirtualRows({
records: [createdRecord],
startingRealIndex: totalNumberOfRecordsToVirtualize,
});
}
},
[
createNewIndexRecord,
upsertRecordsInStore,
loadRecordsToVirtualRows,
totalNumberOfRecordsToVirtualize,
],
);
if (isRecordTableCellsNonEditable) {
return null;
@@ -78,9 +89,24 @@ export const RecordTableNoRecordGroupAddNew = () => {
return null;
}
if (isDefined(nestedRelationCreateThrough)) {
return (
<RecordTableWidgetNestedRelationAddNewRow
dropdownId={`${recordTableId}-nested-relation-add-new`}
nestedRelationCreateThrough={nestedRelationCreateThrough}
onRelationRecordSelected={(relationRecordId) =>
handleCreateRecord({
[nestedRelationCreateThrough.nestedRelationJoinColumnName]:
relationRecordId,
})
}
/>
);
}
return (
<RecordTableActionRow
onClick={handleButtonClick}
onClick={() => handleCreateRecord()}
LeftIcon={IconPlus}
text={t`Add New`}
/>
@@ -6,15 +6,24 @@ import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
import { RecordTableActionRow } from '@/object-record/record-table/record-table-row/components/RecordTableActionRow';
import { RecordTableWidgetNestedRelationAddNewRow } from '@/object-record/record-table-widget/components/RecordTableWidgetNestedRelationAddNewRow';
import { RecordTableWidgetContext } from '@/object-record/record-table-widget/contexts/RecordTableWidgetContext';
import { isRecordTableCellsNonEditableComponentState } from '@/object-record/record-table/states/isRecordTableCellsNonEditableComponentState';
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { type ObjectRecord } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconPlus } from 'twenty-ui/icon';
export const RecordTableRecordGroupSectionAddNew = () => {
const { objectMetadataItem } = useRecordTableContextOrThrow();
const { objectMetadataItem, recordTableId } = useRecordTableContextOrThrow();
const nestedRelationCreateThrough = useContext(
RecordTableWidgetContext,
)?.nestedRelationCreateThrough;
const isRecordTableCellsNonEditable = useAtomComponentStateValue(
isRecordTableCellsNonEditableComponentState,
@@ -56,21 +65,39 @@ export const RecordTableRecordGroupSectionAddNew = () => {
return null;
}
const handleCreateRecord = (recordInput?: Partial<ObjectRecord>) => {
if (!fieldMetadataItem) {
return;
}
createNewIndexRecord({
position: 'last',
[getFieldMetadataItemGqlFieldName(fieldMetadataItem)]:
recordGroupDefinition?.value,
...recordInput,
});
};
if (isDefined(nestedRelationCreateThrough)) {
return (
<RecordTableWidgetNestedRelationAddNewRow
dropdownId={`${recordTableId}-${currentRecordGroupId}-nested-relation-add-new`}
nestedRelationCreateThrough={nestedRelationCreateThrough}
onRelationRecordSelected={(relationRecordId) =>
handleCreateRecord({
[nestedRelationCreateThrough.nestedRelationJoinColumnName]:
relationRecordId,
})
}
/>
);
}
return (
<RecordTableActionRow
LeftIcon={IconPlus}
text={t`Add new`}
onClick={() => {
if (!fieldMetadataItem) {
return;
}
createNewIndexRecord({
position: 'last',
[getFieldMetadataItemGqlFieldName(fieldMetadataItem)]:
recordGroupDefinition?.value,
});
}}
onClick={() => handleCreateRecord()}
/>
);
};
@@ -7,6 +7,7 @@ const FIELD_ID_TEXT = 'field-text-id';
const FIELD_ID_DATE_TIME = 'field-date-time-id';
const FIELD_ID_ADDRESS = 'field-address-id';
const FIELD_ID_NUMBER = 'field-number-id';
const FIELD_ID_RELATION = 'field-relation-id';
const FIELD_ID_UNKNOWN = 'field-unknown-id';
const mockObjectMetadataItem = {
@@ -17,6 +18,13 @@ const mockObjectMetadataItem = {
type: 'TEXT',
options: null,
},
{
id: FIELD_ID_RELATION,
name: 'company',
type: 'RELATION',
options: null,
relation: { type: 'MANY_TO_ONE' },
},
{
id: FIELD_ID_DATE_TIME,
name: 'createdAt',
@@ -229,4 +237,47 @@ describe('buildRecordInputFromFilter', () => {
expect(result).toEqual({ revenue: 42 });
});
it('should prefill the join column for a direct relation filter on the current record', () => {
const result = buildRecordInputFromFilter({
currentRecordFilters: [
createFilter({
fieldMetadataId: FIELD_ID_RELATION,
type: 'RELATION',
operand: ViewFilterOperand.IS,
value: JSON.stringify({
isCurrentRecordSelected: true,
selectedRecordIds: [],
}),
}),
],
objectMetadataItem: mockObjectMetadataItem,
currentRecordId: 'current-record-id',
timeZone: 'UTC',
});
expect(result).toEqual({ companyId: 'current-record-id' });
});
it('should skip relation traversal filters instead of prefilling the join column', () => {
const result = buildRecordInputFromFilter({
currentRecordFilters: [
createFilter({
fieldMetadataId: FIELD_ID_RELATION,
type: 'RELATION',
operand: ViewFilterOperand.IS,
value: JSON.stringify({
isCurrentRecordSelected: true,
selectedRecordIds: [],
}),
relationTargetFieldMetadataId: 'relation-target-field-id',
}),
],
objectMetadataItem: mockObjectMetadataItem,
currentRecordId: 'current-record-id',
timeZone: 'UTC',
});
expect(result).toEqual({});
});
});
@@ -30,6 +30,12 @@ export const buildRecordInputFromFilter = ({
return;
}
// A relation-traversal filter constrains a field of the related record,
// not a column of the record being created, so it cannot be prefilled.
if (isDefined(filter.relationTargetFieldMetadataId)) {
return;
}
if (fieldMetadataItem.type === 'RELATION') {
const value = buildValueFromFilter({
filter,
@@ -9,7 +9,10 @@ import { type SelectableItem } from '@/object-record/select/types/SelectableItem
import { getObjectFilterFields } from '@/object-record/select/utils/getObjectFilterFields';
import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables';
import { makeOrFilterVariables } from '@/object-record/utils/makeOrFilterVariables';
import { type OrderBy } from 'twenty-shared/types';
import {
type OrderBy,
type RecordGqlOperationFilter,
} from 'twenty-shared/types';
export const useRecordsForSelect = ({
searchFilterText,
@@ -19,6 +22,7 @@ export const useRecordsForSelect = ({
excludedRecordIds = [],
objectNameSingular,
allowRequestsToTwentyIcons,
filter,
}: {
searchFilterText: string;
sortOrder?: OrderBy;
@@ -27,6 +31,7 @@ export const useRecordsForSelect = ({
excludedRecordIds?: string[];
objectNameSingular: string;
allowRequestsToTwentyIcons: boolean;
filter?: RecordGqlOperationFilter;
}) => {
const { mapToObjectRecordIdentifier } = useMapToObjectRecordIdentifier({
objectNameSingular,
@@ -49,7 +54,7 @@ export const useRecordsForSelect = ({
const { loading: selectedRecordsLoading, records: selectedRecordsData } =
useFindManyRecords({
filter: selectedIdsFilter,
filter: makeAndFilterVariables([selectedIdsFilter, filter]),
orderBy: orderByField,
objectNameSingular,
skip: !selectedIds.length,
@@ -88,7 +93,11 @@ export const useRecordsForSelect = ({
loading: filteredSelectedRecordsLoading,
records: filteredSelectedRecordsData,
} = useFindManyRecords({
filter: makeAndFilterVariables([...searchFilters, selectedIdsFilter]),
filter: makeAndFilterVariables([
...searchFilters,
selectedIdsFilter,
filter,
]),
orderBy: orderByField,
objectNameSingular,
skip: !selectedIds.length,
@@ -100,7 +109,7 @@ export const useRecordsForSelect = ({
: undefined;
const { loading: recordsToSelectLoading, records: recordsToSelectData } =
useFindManyRecords({
filter: makeAndFilterVariables([...searchFilters, notFilter]),
filter: makeAndFilterVariables([...searchFilters, notFilter, filter]),
limit: limit ?? DEFAULT_SEARCH_REQUEST_LIMIT,
orderBy: orderByField,
objectNameSingular,
@@ -0,0 +1,13 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField';
import { isJunctionRelationField } from '@/object-record/record-field/ui/utils/junction/isJunctionRelationField';
// Junction relation fields also carry MANY_TO_ONE metadata but render through
// a dedicated junction path, mirroring isPlainOneToManyRelationField.
export const isPlainManyToOneRelationField = (
fieldMetadataItem: FieldMetadataItem,
): fieldMetadataItem is FieldMetadataItem & {
relation: NonNullable<FieldMetadataItem['relation']>;
} =>
isManyToOneRelationField(fieldMetadataItem) &&
!isJunctionRelationField(fieldMetadataItem);
@@ -0,0 +1,14 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isOneToManyRelationField } from '@/object-metadata/utils/isOneToManyRelationField';
import { isJunctionRelationField } from '@/object-record/record-field/ui/utils/junction/isJunctionRelationField';
// Junction relation fields also carry ONE_TO_MANY metadata but render through
// a dedicated junction path, mirroring the backend's
// isPlainOneToManyRelationFlatFieldMetadata.
export const isPlainOneToManyRelationField = (
fieldMetadataItem: FieldMetadataItem,
): fieldMetadataItem is FieldMetadataItem & {
relation: NonNullable<FieldMetadataItem['relation']>;
} =>
isOneToManyRelationField(fieldMetadataItem) &&
!isJunctionRelationField(fieldMetadataItem);
@@ -167,6 +167,7 @@ export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql`
fieldDisplayMode
fieldMetadataId
viewId
nestedRelationFieldMetadataId
}
... on FieldRichTextConfiguration {
configurationType
@@ -9,4 +9,5 @@ export type FieldConfiguration = {
fieldMetadataId: string;
fieldDisplayMode: FieldDisplayMode;
viewId?: string;
nestedRelationFieldMetadataId?: string | null;
};
@@ -1,14 +1,25 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { RecordFilterValueDependenciesContext } from '@/object-record/record-filter/contexts/RecordFilterValueDependenciesContext';
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import { type FieldRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { RECORD_TABLE_ROW_HEIGHT } from '@/object-record/record-table/constants/RecordTableRowHeight';
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { RecordTableWidgetRendererContent } from '@/page-layout/widgets/record-table/components/RecordTableWidgetRendererContent';
import { getFieldWidgetNestedRelationCreateThrough } from '@/page-layout/widgets/field/utils/getFieldWidgetNestedRelationCreateThrough';
import { isFieldWidget } from '@/page-layout/widgets/field/utils/isFieldWidget';
import { resolveFieldWidgetNestedRelation } from '@/page-layout/widgets/field/utils/resolveFieldWidgetNestedRelation';
import { useCurrentWidget } from '@/page-layout/widgets/hooks/useCurrentWidget';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { styled } from '@linaria/react';
import { isDefined } from 'twenty-shared/utils';
import { isNonEmptyString } from '@sniptt/guards';
import { useMemo } from 'react';
import {
computeRelationGqlFieldJoinColumnName,
isDefined,
} from 'twenty-shared/utils';
import { RelationType } from '~/generated-metadata/graphql';
const FIELD_WIDGET_RELATION_TABLE_MAX_VISIBLE_RECORDS = 20;
@@ -38,19 +49,118 @@ export const FieldWidgetRelationTable = ({
const { isInSidePanel } = useLayoutRenderingContext();
const { objectMetadataItems } = useObjectMetadataItems();
const viewId = isFieldWidget(widget)
? widget.configuration.viewId
: undefined;
const nestedRelationFieldMetadataId = isFieldWidget(widget)
? widget.configuration.nestedRelationFieldMetadataId
: undefined;
const relationObjectMetadataId =
fieldDefinition.metadata.relationObjectMetadataId;
const recordPageObjectMetadataNameSingular =
fieldDefinition.metadata.objectMetadataNameSingular;
// Memoized so the derived picker parameters below keep a stable identity
// and do not churn the widget provider's context value on every render.
const resolvedNestedRelation = useMemo(
() =>
resolveFieldWidgetNestedRelation({
objectMetadataItems,
relationTargetObjectMetadataId: relationObjectMetadataId,
nestedRelationFieldMetadataId,
}),
[
objectMetadataItems,
relationObjectMetadataId,
nestedRelationFieldMetadataId,
],
);
// A widget with a broken nested relation (deleted or deactivated second hop)
// resolves to no object and so renders nothing, rather than falling back to
// the first hop's records and silently showing a different object than the
// widget title claims.
const tableObjectMetadataId = isDefined(nestedRelationFieldMetadataId)
? resolvedNestedRelation?.nestedRelationTargetObjectMetadataItem.id
: relationObjectMetadataId;
const {
targetFieldMetadataName,
relationObjectMetadataNameSingular,
relationType,
fieldName,
} = fieldDefinition.metadata;
const nestedRelationCreateThrough = useMemo(
() =>
isDefined(resolvedNestedRelation)
? getFieldWidgetNestedRelationCreateThrough({
fieldRelationMetadata: {
targetFieldMetadataName,
relationObjectMetadataNameSingular,
relationType,
},
nestedRelationFieldMetadataItem:
resolvedNestedRelation.nestedRelationFieldMetadataItem,
recordId,
})
: undefined,
[
resolvedNestedRelation,
targetFieldMetadataName,
relationObjectMetadataNameSingular,
relationType,
recordId,
],
);
// A many-to-one first hop points at a single intermediate record, so the
// terminal view is scoped directly by it: the intermediate becomes the
// filter's current record, read from the current record's join column.
const isManyToOneNestedChain =
isDefined(nestedRelationFieldMetadataId) &&
relationType === RelationType.MANY_TO_ONE;
const intermediateRecordId = useAtomFamilySelectorValue(
recordStoreFamilySelector,
{
recordId,
fieldName: computeRelationGqlFieldJoinColumnName({ name: fieldName }),
},
);
const filterCurrentRecord = useMemo(() => {
if (!isManyToOneNestedChain) {
return isDefined(recordPageObjectMetadataNameSingular)
? {
id: recordId,
objectMetadataNameSingular: recordPageObjectMetadataNameSingular,
}
: undefined;
}
return isNonEmptyString(intermediateRecordId)
? {
id: intermediateRecordId,
objectMetadataNameSingular: relationObjectMetadataNameSingular,
}
: undefined;
}, [
isManyToOneNestedChain,
recordId,
recordPageObjectMetadataNameSingular,
intermediateRecordId,
relationObjectMetadataNameSingular,
]);
if (
!isDefined(viewId) ||
!isDefined(relationObjectMetadataId) ||
!isDefined(recordPageObjectMetadataNameSingular)
!isDefined(tableObjectMetadataId) ||
!isDefined(recordPageObjectMetadataNameSingular) ||
!isDefined(filterCurrentRecord)
) {
return null;
}
@@ -58,20 +168,18 @@ export const FieldWidgetRelationTable = ({
return (
<RecordFilterValueDependenciesContext.Provider
value={{
currentRecord: {
id: recordId,
objectMetadataNameSingular: recordPageObjectMetadataNameSingular,
},
currentRecord: filterCurrentRecord,
}}
>
<StyledContainer>
<RecordTableWidgetRendererContent
objectMetadataId={relationObjectMetadataId}
objectMetadataId={tableObjectMetadataId}
viewId={viewId}
widgetId={widget.id}
isReadOnly={isPageLayoutInEditMode}
isEmptyStateHidden
instanceIdSuffix={`${recordId}${isInSidePanel ? '-side-panel' : ''}`}
nestedRelationCreateThrough={nestedRelationCreateThrough}
/>
</StyledContainer>
</RecordFilterValueDependenciesContext.Provider>
@@ -0,0 +1,77 @@
import { type FieldRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { getFieldWidgetNestedRelationCreateThrough } from '@/page-layout/widgets/field/utils/getFieldWidgetNestedRelationCreateThrough';
import { RelationType } from '~/generated-metadata/graphql';
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
const personObjectMetadataItem = getMockObjectMetadataItemOrThrow('person');
const personOpportunitiesField = personObjectMetadataItem.fields.find(
(field) => field.name === 'pointOfContactForOpportunities',
);
const companyPeopleFieldRelationMetadata = {
relationObjectMetadataNameSingular: 'person',
targetFieldMetadataName: 'company',
relationType: RelationType.ONE_TO_MANY,
} as FieldRelationMetadata;
describe('getFieldWidgetNestedRelationCreateThrough', () => {
it('should scope pickable records to the current record and target the nested relation join column', () => {
const createThrough = getFieldWidgetNestedRelationCreateThrough({
fieldRelationMetadata: companyPeopleFieldRelationMetadata,
nestedRelationFieldMetadataItem: personOpportunitiesField!,
recordId: 'current-company-id',
});
expect(createThrough).toEqual({
relationObjectMetadataNameSingular: 'person',
relationRecordsFilter: { companyId: { eq: 'current-company-id' } },
nestedRelationJoinColumnName: 'pointOfContactId',
});
});
it('should return undefined for a many-to-one first hop', () => {
// The intermediate is unambiguous there, so the created record's join
// column is prefilled from the seeded direct filter instead of a picker.
expect(
getFieldWidgetNestedRelationCreateThrough({
fieldRelationMetadata: {
...companyPeopleFieldRelationMetadata,
relationType: RelationType.MANY_TO_ONE,
},
nestedRelationFieldMetadataItem: personOpportunitiesField!,
recordId: 'current-company-id',
}),
).toBeUndefined();
});
it('should return undefined without the relation inverse field name', () => {
expect(
getFieldWidgetNestedRelationCreateThrough({
fieldRelationMetadata: {
...companyPeopleFieldRelationMetadata,
targetFieldMetadataName: undefined,
},
nestedRelationFieldMetadataItem: personOpportunitiesField!,
recordId: 'current-company-id',
}),
).toBeUndefined();
});
it('should return undefined without a nested relation inverse field', () => {
const personCompanyField = personObjectMetadataItem.fields.find(
(field) => field.name === 'company',
);
expect(
getFieldWidgetNestedRelationCreateThrough({
fieldRelationMetadata: companyPeopleFieldRelationMetadata,
nestedRelationFieldMetadataItem: {
...personCompanyField!,
relation: null,
},
recordId: 'current-company-id',
}),
).toBeUndefined();
});
});
@@ -0,0 +1,99 @@
import { getFieldWidgetRelationTraversal } from '@/page-layout/widgets/field/utils/getFieldWidgetRelationTraversal';
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
const companyObjectMetadataItem = getMockObjectMetadataItemOrThrow('company');
const personObjectMetadataItem = getMockObjectMetadataItemOrThrow('person');
const opportunityObjectMetadataItem =
getMockObjectMetadataItemOrThrow('opportunity');
const companyPeopleField = companyObjectMetadataItem.fields.find(
(field) => field.name === 'people',
);
const personOpportunitiesField = personObjectMetadataItem.fields.find(
(field) => field.name === 'pointOfContactForOpportunities',
);
const personCompanyField = personObjectMetadataItem.fields.find(
(field) => field.name === 'company',
);
const companyOpportunitiesField = companyObjectMetadataItem.fields.find(
(field) => field.name === 'opportunities',
);
describe('getFieldWidgetRelationTraversal', () => {
it('should scope a direct widget through the relation own inverse', () => {
const traversal = getFieldWidgetRelationTraversal({
sourceFieldMetadataItem: companyPeopleField,
});
expect(traversal.targetObjectMetadataId).toBe(personObjectMetadataItem.id);
expect(traversal.inverseFieldMetadataId).toBe(
companyPeopleField?.relation?.targetFieldMetadata.id,
);
expect(traversal.relationTargetFieldMetadataId).toBeNull();
});
it('should scope a nested widget through the last hop, traversing the first', () => {
const traversal = getFieldWidgetRelationTraversal({
sourceFieldMetadataItem: companyPeopleField,
nestedRelationFieldMetadataItem: personOpportunitiesField,
});
// The embedded view lists the terminal object...
expect(traversal.targetObjectMetadataId).toBe(
opportunityObjectMetadataItem.id,
);
// ...scoped by the second hop's inverse (opportunity -> person)...
expect(traversal.inverseFieldMetadataId).toBe(
personOpportunitiesField?.relation?.targetFieldMetadata.id,
);
// ...traversed one relation further out via the first hop's inverse
// (person -> company), which is what makes it a two-hop filter.
expect(traversal.relationTargetFieldMetadataId).toBe(
companyPeopleField?.relation?.targetFieldMetadata.id,
);
});
it('should not confuse the two hops', () => {
const traversal = getFieldWidgetRelationTraversal({
sourceFieldMetadataItem: companyPeopleField,
nestedRelationFieldMetadataItem: personOpportunitiesField,
});
expect(traversal.inverseFieldMetadataId).not.toBe(
traversal.relationTargetFieldMetadataId,
);
expect(traversal.targetObjectMetadataId).not.toBe(
personObjectMetadataItem.id,
);
});
it('should scope a many-to-one first hop directly, without traversal', () => {
const traversal = getFieldWidgetRelationTraversal({
sourceFieldMetadataItem: personCompanyField,
nestedRelationFieldMetadataItem: companyOpportunitiesField,
});
expect(traversal.targetObjectMetadataId).toBe(
opportunityObjectMetadataItem.id,
);
expect(traversal.inverseFieldMetadataId).toBe(
companyOpportunitiesField?.relation?.targetFieldMetadata.id,
);
// The intermediate is the single record the current record points at, so
// the seeded filter is a direct one on the terminal object.
expect(traversal.relationTargetFieldMetadataId).toBeNull();
});
it('should return an empty traversal without a source field', () => {
expect(
getFieldWidgetRelationTraversal({ sourceFieldMetadataItem: undefined }),
).toEqual({
targetObjectMetadataId: undefined,
inverseFieldMetadataId: undefined,
relationTargetFieldMetadataId: null,
});
});
});
@@ -0,0 +1,81 @@
import { resolveFieldWidgetNestedRelation } from '@/page-layout/widgets/field/utils/resolveFieldWidgetNestedRelation';
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
import { getTestEnrichedObjectMetadataItemsMock } from '~/testing/utils/getTestEnrichedObjectMetadataItemsMock';
const objectMetadataItems = getTestEnrichedObjectMetadataItemsMock();
const companyObjectMetadataItem = getMockObjectMetadataItemOrThrow('company');
const personObjectMetadataItem = getMockObjectMetadataItemOrThrow('person');
const opportunityObjectMetadataItem =
getMockObjectMetadataItemOrThrow('opportunity');
const companyPeopleField = companyObjectMetadataItem.fields.find(
(field) => field.name === 'people',
);
const personOpportunitiesField = personObjectMetadataItem.fields.find(
(field) => field.name === 'pointOfContactForOpportunities',
);
describe('resolveFieldWidgetNestedRelation', () => {
it('should resolve a valid two-hop chain to the terminal object', () => {
const resolved = resolveFieldWidgetNestedRelation({
objectMetadataItems,
relationTargetObjectMetadataId:
companyPeopleField?.relation?.targetObjectMetadata.id,
nestedRelationFieldMetadataId: personOpportunitiesField?.id,
});
expect(resolved).toBeDefined();
expect(resolved?.nestedRelationFieldMetadataItem.id).toBe(
personOpportunitiesField?.id,
);
expect(resolved?.nestedRelationTargetObjectMetadataItem.id).toBe(
opportunityObjectMetadataItem.id,
);
});
it('should return undefined without a nested relation field id', () => {
expect(
resolveFieldWidgetNestedRelation({
objectMetadataItems,
relationTargetObjectMetadataId: personObjectMetadataItem.id,
nestedRelationFieldMetadataId: null,
}),
).toBeUndefined();
});
it('should return undefined when the nested field does not exist on the target object', () => {
expect(
resolveFieldWidgetNestedRelation({
objectMetadataItems,
relationTargetObjectMetadataId: personObjectMetadataItem.id,
nestedRelationFieldMetadataId: 'deleted-field-id',
}),
).toBeUndefined();
});
it('should return undefined when the nested field is not a one-to-many relation', () => {
const personCompanyField = personObjectMetadataItem.fields.find(
(field) => field.name === 'company',
);
expect(
resolveFieldWidgetNestedRelation({
objectMetadataItems,
relationTargetObjectMetadataId: personObjectMetadataItem.id,
nestedRelationFieldMetadataId: personCompanyField?.id,
}),
).toBeUndefined();
});
it('should return undefined when the target object cannot be found', () => {
expect(
resolveFieldWidgetNestedRelation({
objectMetadataItems,
relationTargetObjectMetadataId: 'unknown-object-id',
nestedRelationFieldMetadataId: personOpportunitiesField?.id,
}),
).toBeUndefined();
});
});
@@ -0,0 +1,10 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isFieldWidgetEligibleNestedField } from '@/page-layout/widgets/field/utils/isFieldWidgetEligibleNestedField';
export const getFieldWidgetEligibleNestedFields = (
relationTargetObjectMetadataItem: EnrichedObjectMetadataItem,
): FieldMetadataItem[] =>
relationTargetObjectMetadataItem.readableFields
.filter(isFieldWidgetEligibleNestedField)
.toSorted((fieldA, fieldB) => fieldA.label.localeCompare(fieldB.label));
@@ -0,0 +1,54 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type FieldRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { type RecordTableWidgetNestedRelationCreateThrough } from '@/object-record/record-table-widget/contexts/RecordTableWidgetContext';
import { isNonEmptyString } from '@sniptt/guards';
import { computeRelationGqlFieldJoinColumnName } from 'twenty-shared/utils';
import { RelationType } from '~/generated-metadata/graphql';
export const getFieldWidgetNestedRelationCreateThrough = ({
fieldRelationMetadata,
nestedRelationFieldMetadataItem,
recordId,
}: {
fieldRelationMetadata: Pick<
FieldRelationMetadata,
| 'targetFieldMetadataName'
| 'relationObjectMetadataNameSingular'
| 'relationType'
>;
nestedRelationFieldMetadataItem: FieldMetadataItem;
recordId: string;
}): RecordTableWidgetNestedRelationCreateThrough | undefined => {
// Only a one-to-many first hop leaves the record to create through
// ambiguous. A many-to-one first hop points at a single intermediate
// record, so the created record's join column is prefilled from the
// seeded direct filter instead of a picker.
if (fieldRelationMetadata.relationType !== RelationType.ONE_TO_MANY) {
return undefined;
}
const relationInverseFieldName =
fieldRelationMetadata.targetFieldMetadataName;
const nestedRelationInverseFieldName =
nestedRelationFieldMetadataItem.relation?.targetFieldMetadata.name;
if (
!isNonEmptyString(relationInverseFieldName) ||
!isNonEmptyString(nestedRelationInverseFieldName)
) {
return undefined;
}
return {
relationObjectMetadataNameSingular:
fieldRelationMetadata.relationObjectMetadataNameSingular,
relationRecordsFilter: {
[computeRelationGqlFieldJoinColumnName({
name: relationInverseFieldName,
})]: { eq: recordId },
},
nestedRelationJoinColumnName: computeRelationGqlFieldJoinColumnName({
name: nestedRelationInverseFieldName,
}),
};
};
@@ -0,0 +1,46 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isOneToManyRelationField } from '@/object-metadata/utils/isOneToManyRelationField';
import { isDefined } from 'twenty-shared/utils';
type GetFieldWidgetRelationTraversalArgs = {
sourceFieldMetadataItem: FieldMetadataItem | undefined;
nestedRelationFieldMetadataItem?: FieldMetadataItem;
};
type FieldWidgetRelationTraversal = {
targetObjectMetadataId: string | undefined;
inverseFieldMetadataId: string | undefined;
relationTargetFieldMetadataId: string | null;
};
// The widget's embedded view lists records of the last hop's target object,
// scoped back to the current record through that hop's inverse relation. A
// nested widget scopes one relation further out, which the seeded view filter
// expresses as relationTargetFieldMetadataId: the first hop's inverse.
export const getFieldWidgetRelationTraversal = ({
sourceFieldMetadataItem,
nestedRelationFieldMetadataItem,
}: GetFieldWidgetRelationTraversalArgs): FieldWidgetRelationTraversal => {
const lastHopFieldMetadataItem =
nestedRelationFieldMetadataItem ?? sourceFieldMetadataItem;
// Only a one-to-many first hop needs the traversal: its intermediate
// records carry the join column pointing back at the current record. A
// many-to-one first hop points at a single intermediate record, which the
// widget supplies as the filter's current record, so the seeded filter
// stays a direct one.
const shouldTraverseFirstHop =
isDefined(nestedRelationFieldMetadataItem) &&
isDefined(sourceFieldMetadataItem) &&
isOneToManyRelationField(sourceFieldMetadataItem);
return {
targetObjectMetadataId:
lastHopFieldMetadataItem?.relation?.targetObjectMetadata.id,
inverseFieldMetadataId:
lastHopFieldMetadataItem?.relation?.targetFieldMetadata.id,
relationTargetFieldMetadataId: shouldTraverseFirstHop
? (sourceFieldMetadataItem.relation?.targetFieldMetadata.id ?? null)
: null,
};
};
@@ -0,0 +1,10 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isPlainOneToManyRelationField } from '@/object-record/utils/isPlainOneToManyRelationField';
export const isFieldWidgetEligibleNestedField = (
fieldMetadataItem: FieldMetadataItem,
): fieldMetadataItem is FieldMetadataItem & {
relation: NonNullable<FieldMetadataItem['relation']>;
} =>
(fieldMetadataItem.isActive ?? false) &&
isPlainOneToManyRelationField(fieldMetadataItem);
@@ -0,0 +1,15 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isPlainManyToOneRelationField } from '@/object-record/utils/isPlainManyToOneRelationField';
import { isPlainOneToManyRelationField } from '@/object-record/utils/isPlainOneToManyRelationField';
// A nested chain can start from either relation direction: a one-to-many
// first hop scopes the terminal view through a relation traversal filter,
// while a many-to-one first hop scopes it directly by the single related
// record the current record points at.
export const isFieldWidgetEligibleNestedParentField = (
fieldMetadataItem: FieldMetadataItem,
): fieldMetadataItem is FieldMetadataItem & {
relation: NonNullable<FieldMetadataItem['relation']>;
} =>
isPlainOneToManyRelationField(fieldMetadataItem) ||
isPlainManyToOneRelationField(fieldMetadataItem);
@@ -0,0 +1,73 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isFieldWidgetEligibleNestedField } from '@/page-layout/widgets/field/utils/isFieldWidgetEligibleNestedField';
import { isDefined } from 'twenty-shared/utils';
type ResolveFieldWidgetNestedRelationArgs = {
objectMetadataItems: EnrichedObjectMetadataItem[];
relationTargetObjectMetadataId: string | undefined;
nestedRelationFieldMetadataId: string | null | undefined;
};
type ResolvedFieldWidgetNestedRelation = {
nestedRelationFieldMetadataItem: FieldMetadataItem & {
relation: NonNullable<FieldMetadataItem['relation']>;
};
nestedRelationTargetObjectMetadataItem: EnrichedObjectMetadataItem;
};
// Resolves the second hop of a nested relation field widget: the one-to-many
// relation field on the first hop's target object, and the terminal object
// whose records the widget lists. Returns undefined when the chain is broken
// (deleted or deactivated field) so callers can degrade gracefully.
export const resolveFieldWidgetNestedRelation = ({
objectMetadataItems,
relationTargetObjectMetadataId,
nestedRelationFieldMetadataId,
}: ResolveFieldWidgetNestedRelationArgs):
| ResolvedFieldWidgetNestedRelation
| undefined => {
if (
!isDefined(relationTargetObjectMetadataId) ||
!isDefined(nestedRelationFieldMetadataId)
) {
return undefined;
}
const relationTargetObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.id === relationTargetObjectMetadataId,
);
if (!isDefined(relationTargetObjectMetadataItem)) {
return undefined;
}
const nestedRelationFieldMetadataItem =
relationTargetObjectMetadataItem.readableFields.find(
(fieldMetadataItem) =>
fieldMetadataItem.id === nestedRelationFieldMetadataId,
);
if (
!isDefined(nestedRelationFieldMetadataItem) ||
!isFieldWidgetEligibleNestedField(nestedRelationFieldMetadataItem)
) {
return undefined;
}
const nestedRelationTargetObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.id ===
nestedRelationFieldMetadataItem.relation.targetObjectMetadata.id,
);
if (!isDefined(nestedRelationTargetObjectMetadataItem)) {
return undefined;
}
return {
nestedRelationFieldMetadataItem,
nestedRelationTargetObjectMetadataItem,
};
};
@@ -71,7 +71,14 @@ export const useWidgetActions = ({
isFieldRelation(fieldDefinition) &&
fieldDefinition.metadata.relationType === RelationType.ONE_TO_MANY;
if (isOneToManyRelation) {
// "See all" links to the relation field's own index, which lists the first
// hop. A nested widget lists the second hop, so the link would point at a
// different object than the widget shows.
const isNestedRelationWidget =
isFieldWidget(widget) &&
isDefined(widget.configuration.nestedRelationFieldMetadataId);
if (isOneToManyRelation && !isNestedRelationWidget) {
actions.push({
id: 'see-all',
position: 0,
@@ -4,6 +4,7 @@ import { RecordBoardWidget } from '@/object-record/record-board-widget/component
import { RecordCalendarWidget } from '@/object-record/record-calendar-widget/components/RecordCalendarWidget';
import { RecordTableWidget } from '@/object-record/record-table-widget/components/RecordTableWidget';
import { RecordTableWidgetProvider } from '@/object-record/record-table-widget/components/RecordTableWidgetProvider';
import { type RecordTableWidgetNestedRelationCreateThrough } from '@/object-record/record-table-widget/contexts/RecordTableWidgetContext';
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { recordTableWidgetViewDraftByWidgetIdComponentFamilySelector } from '@/page-layout/states/selectors/recordTableWidgetViewDraftByWidgetIdComponentFamilySelector';
import { constructViewFromRecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/utils/constructViewFromRecordTableWidgetViewSnapshot';
@@ -25,6 +26,7 @@ type RecordTableWidgetRendererContentProps = {
isEmptyStateHidden?: boolean;
recordLimit?: number;
instanceIdSuffix?: string;
nestedRelationCreateThrough?: RecordTableWidgetNestedRelationCreateThrough;
};
export const RecordTableWidgetRendererContent = ({
@@ -35,6 +37,7 @@ export const RecordTableWidgetRendererContent = ({
isEmptyStateHidden = false,
recordLimit,
instanceIdSuffix,
nestedRelationCreateThrough,
}: RecordTableWidgetRendererContentProps) => {
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataId,
@@ -88,6 +91,7 @@ export const RecordTableWidgetRendererContent = ({
widgetId={widgetId}
recordLimit={recordLimit}
instanceIdSuffix={instanceIdSuffix}
nestedRelationCreateThrough={nestedRelationCreateThrough}
contextStoreViewType={
isKanbanLayout
? ContextStoreViewType.Kanban
@@ -0,0 +1,107 @@
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { useAddDraftViewForFieldRelationTableWidget } from '@/page-layout/widgets/record-table/hooks/useAddDraftViewForFieldRelationTableWidget';
import { act, renderHook } from '@testing-library/react';
import { createStore, Provider as JotaiProvider } from 'jotai';
import { type ReactNode } from 'react';
import { ViewFilterOperand } from 'twenty-shared/types';
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
import { getTestEnrichedObjectMetadataItemsMock } from '~/testing/utils/getTestEnrichedObjectMetadataItemsMock';
import { setTestObjectMetadataItemsInMetadataStore } from '~/testing/utils/setTestObjectMetadataItemsInMetadataStore';
const PAGE_LAYOUT_ID = 'page-layout-id';
const WIDGET_ID = 'widget-id';
const INVERSE_FIELD_METADATA_ID = 'inverse-field-metadata-id';
const RELATION_TARGET_FIELD_METADATA_ID = 'relation-target-field-metadata-id';
const opportunityObjectMetadataItem =
getMockObjectMetadataItemOrThrow('opportunity');
const getWrapper =
(store: ReturnType<typeof createStore>) =>
({ children }: { children: ReactNode }) => (
<JotaiProvider store={store}>{children}</JotaiProvider>
);
describe('useAddDraftViewForFieldRelationTableWidget', () => {
it('should seed a relation traversal filter for a nested relation widget', () => {
const store = createStore();
setTestObjectMetadataItemsInMetadataStore(
store,
getTestEnrichedObjectMetadataItemsMock(),
);
const { result } = renderHook(
() => useAddDraftViewForFieldRelationTableWidget(PAGE_LAYOUT_ID),
{ wrapper: getWrapper(store) },
);
let viewId: string | undefined;
act(() => {
viewId = result.current.addDraftViewForFieldRelationTableWidget({
widgetId: WIDGET_ID,
targetObjectMetadataId: opportunityObjectMetadataItem.id,
inverseFieldMetadataId: INVERSE_FIELD_METADATA_ID,
relationTargetFieldMetadataId: RELATION_TARGET_FIELD_METADATA_ID,
});
});
const draft = store.get(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: PAGE_LAYOUT_ID,
}),
);
expect(viewId).toBeDefined();
expect(draft[WIDGET_ID].view.objectMetadataId).toBe(
opportunityObjectMetadataItem.id,
);
expect(draft[WIDGET_ID].viewFilters).toEqual([
expect.objectContaining({
fieldMetadataId: INVERSE_FIELD_METADATA_ID,
relationTargetFieldMetadataId: RELATION_TARGET_FIELD_METADATA_ID,
operand: ViewFilterOperand.IS,
value: JSON.stringify({
selectedRecordIds: [],
isCurrentRecordSelected: true,
}),
}),
]);
});
it('should seed a direct filter without relation traversal by default', () => {
const store = createStore();
setTestObjectMetadataItemsInMetadataStore(
store,
getTestEnrichedObjectMetadataItemsMock(),
);
const { result } = renderHook(
() => useAddDraftViewForFieldRelationTableWidget(PAGE_LAYOUT_ID),
{ wrapper: getWrapper(store) },
);
act(() => {
result.current.addDraftViewForFieldRelationTableWidget({
widgetId: WIDGET_ID,
targetObjectMetadataId: opportunityObjectMetadataItem.id,
inverseFieldMetadataId: INVERSE_FIELD_METADATA_ID,
});
});
const draft = store.get(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: PAGE_LAYOUT_ID,
}),
);
expect(draft[WIDGET_ID].viewFilters).toEqual([
expect.objectContaining({
fieldMetadataId: INVERSE_FIELD_METADATA_ID,
relationTargetFieldMetadataId: null,
}),
]);
});
});
@@ -0,0 +1,180 @@
import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/recordTableWidgetViewDraftComponentState';
import { useResolveFieldWidgetRelationTableViewIdChange } from '@/page-layout/widgets/record-table/hooks/useResolveFieldWidgetRelationTableViewIdChange';
import { act, renderHook } from '@testing-library/react';
import { createStore, Provider as JotaiProvider } from 'jotai';
import { type ReactNode } from 'react';
import { FieldDisplayMode } from '~/generated-metadata/graphql';
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
import { getTestEnrichedObjectMetadataItemsMock } from '~/testing/utils/getTestEnrichedObjectMetadataItemsMock';
import { setTestObjectMetadataItemsInMetadataStore } from '~/testing/utils/setTestObjectMetadataItemsInMetadataStore';
const PAGE_LAYOUT_ID = 'page-layout-id';
const WIDGET_ID = 'widget-id';
const companyObjectMetadataItem = getMockObjectMetadataItemOrThrow('company');
const personObjectMetadataItem = getMockObjectMetadataItemOrThrow('person');
const opportunityObjectMetadataItem =
getMockObjectMetadataItemOrThrow('opportunity');
const companyPeopleField = companyObjectMetadataItem.fields.find(
(field) => field.name === 'people',
);
const personOpportunitiesField = personObjectMetadataItem.fields.find(
(field) => field.name === 'pointOfContactForOpportunities',
);
const personCompanyField = personObjectMetadataItem.fields.find(
(field) => field.name === 'company',
);
const companyOpportunitiesField = companyObjectMetadataItem.fields.find(
(field) => field.name === 'opportunities',
);
const getWrapper =
(store: ReturnType<typeof createStore>) =>
({ children }: { children: ReactNode }) => (
<JotaiProvider store={store}>{children}</JotaiProvider>
);
const renderResolveHook = () => {
const store = createStore();
setTestObjectMetadataItemsInMetadataStore(
store,
getTestEnrichedObjectMetadataItemsMock(),
);
const { result } = renderHook(
() => useResolveFieldWidgetRelationTableViewIdChange(PAGE_LAYOUT_ID),
{ wrapper: getWrapper(store) },
);
return { result, store };
};
describe('useResolveFieldWidgetRelationTableViewIdChange', () => {
it('should regenerate a view on the terminal object when the chain changes', () => {
const { result, store } = renderResolveHook();
let change: { viewId?: string | null } | undefined;
act(() => {
change = result.current.resolveFieldWidgetRelationTableViewIdChange({
selectedField: companyPeopleField,
selectedNestedField: personOpportunitiesField,
nextDisplayMode: FieldDisplayMode.TABLE,
isSelectingDifferentChain: true,
widgetId: WIDGET_ID,
currentViewId: 'previous-view-id',
});
});
expect(change?.viewId).toBeDefined();
const draft = store.get(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: PAGE_LAYOUT_ID,
}),
);
expect(draft[WIDGET_ID].view.objectMetadataId).toBe(
opportunityObjectMetadataItem.id,
);
});
it('should regenerate a view for a many-to-one first hop chain', () => {
const { result, store } = renderResolveHook();
let change: { viewId?: string | null } | undefined;
act(() => {
change = result.current.resolveFieldWidgetRelationTableViewIdChange({
selectedField: personCompanyField,
selectedNestedField: companyOpportunitiesField,
nextDisplayMode: FieldDisplayMode.TABLE,
isSelectingDifferentChain: true,
widgetId: WIDGET_ID,
currentViewId: undefined,
});
});
expect(change?.viewId).toBeDefined();
const draft = store.get(
recordTableWidgetViewDraftComponentState.atomFamily({
instanceId: PAGE_LAYOUT_ID,
}),
);
expect(draft[WIDGET_ID].view.objectMetadataId).toBe(
opportunityObjectMetadataItem.id,
);
// The intermediate is the single record the current record points at, so
// the seeded filter carries no relation traversal.
expect(draft[WIDGET_ID].viewFilters).toEqual([
expect.objectContaining({
fieldMetadataId:
companyOpportunitiesField?.relation?.targetFieldMetadata.id,
relationTargetFieldMetadataId: null,
}),
]);
});
it('should regenerate a missing view even when the chain is unchanged', () => {
const { result } = renderResolveHook();
let change: { viewId?: string | null } | undefined;
act(() => {
change = result.current.resolveFieldWidgetRelationTableViewIdChange({
selectedField: companyPeopleField,
selectedNestedField: personOpportunitiesField,
nextDisplayMode: FieldDisplayMode.TABLE,
isSelectingDifferentChain: false,
widgetId: WIDGET_ID,
currentViewId: undefined,
});
});
expect(change?.viewId).toBeDefined();
});
it('should keep the current view when the chain and view are unchanged', () => {
const { result } = renderResolveHook();
let change: { viewId?: string | null } | undefined;
act(() => {
change = result.current.resolveFieldWidgetRelationTableViewIdChange({
selectedField: companyPeopleField,
selectedNestedField: personOpportunitiesField,
nextDisplayMode: FieldDisplayMode.TABLE,
isSelectingDifferentChain: false,
widgetId: WIDGET_ID,
currentViewId: 'current-view-id',
});
});
expect(change).toBeUndefined();
});
it('should clear a stale view when the next display mode is not table', () => {
const { result } = renderResolveHook();
let change: { viewId?: string | null } | undefined;
act(() => {
change = result.current.resolveFieldWidgetRelationTableViewIdChange({
selectedField: companyPeopleField,
nextDisplayMode: FieldDisplayMode.CARD,
isSelectingDifferentChain: true,
widgetId: WIDGET_ID,
currentViewId: 'previous-view-id',
});
});
expect(change).toEqual({ viewId: undefined });
});
});
@@ -22,11 +22,17 @@ export const useAddDraftViewForFieldRelationTableWidget = (
const store = useStore();
const addDraftViewForFieldRelationTableWidget = useCallback(
(
widgetId: string,
targetObjectMetadataId: string,
inverseFieldMetadataId: string,
): string | undefined => {
({
widgetId,
targetObjectMetadataId,
inverseFieldMetadataId,
relationTargetFieldMetadataId,
}: {
widgetId: string;
targetObjectMetadataId: string;
inverseFieldMetadataId: string;
relationTargetFieldMetadataId?: string | null;
}): string | undefined => {
const targetObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.id === targetObjectMetadataId,
@@ -55,6 +61,8 @@ export const useAddDraftViewForFieldRelationTableWidget = (
viewFilterGroupId: null,
positionInViewFilterGroup: null,
subFieldName: null,
relationTargetFieldMetadataId:
relationTargetFieldMetadataId ?? null,
},
],
};
@@ -1,17 +1,19 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isOneToManyRelationField } from '@/object-metadata/utils/isOneToManyRelationField';
import { getFieldWidgetRelationTraversal } from '@/page-layout/widgets/field/utils/getFieldWidgetRelationTraversal';
import { isFieldWidgetEligibleNestedParentField } from '@/page-layout/widgets/field/utils/isFieldWidgetEligibleNestedParentField';
import { useAddDraftViewForFieldRelationTableWidget } from '@/page-layout/widgets/record-table/hooks/useAddDraftViewForFieldRelationTableWidget';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
FieldDisplayMode,
type FieldConfiguration,
RelationType,
} from '~/generated-metadata/graphql';
type ResolveFieldWidgetRelationTableViewIdChangeArgs = {
selectedField: FieldMetadataItem | undefined;
currentDisplayMode: FieldDisplayMode | undefined;
isSelectingDifferentField: boolean;
selectedNestedField?: FieldMetadataItem;
nextDisplayMode: FieldDisplayMode | undefined;
isSelectingDifferentChain: boolean;
widgetId: string | undefined;
currentViewId: string | null | undefined;
};
@@ -22,42 +24,59 @@ export const useResolveFieldWidgetRelationTableViewIdChange = (
const { addDraftViewForFieldRelationTableWidget } =
useAddDraftViewForFieldRelationTableWidget(pageLayoutId);
// The embedded view must always list the selected chain's terminal object.
// Whenever the selection results in a table widget, a fresh draft view is
// generated on a chain change or a missing view id; otherwise a view id
// belonging to the previous chain is cleared so the layout dropdown can
// lazily create the right one.
const resolveFieldWidgetRelationTableViewIdChange = ({
selectedField,
currentDisplayMode,
isSelectingDifferentField,
selectedNestedField,
nextDisplayMode,
isSelectingDifferentChain,
widgetId,
currentViewId,
}: ResolveFieldWidgetRelationTableViewIdChangeArgs):
| Pick<FieldConfiguration, 'viewId'>
| undefined => {
const targetObjectMetadataId =
selectedField?.relation?.targetObjectMetadata.id;
const targetFieldMetadataId =
selectedField?.relation?.targetFieldMetadata.id;
const {
targetObjectMetadataId,
inverseFieldMetadataId,
relationTargetFieldMetadataId,
} = getFieldWidgetRelationTraversal({
sourceFieldMetadataItem: selectedField,
nestedRelationFieldMetadataItem: selectedNestedField,
});
const isValidRelationChain =
isDefined(selectedField) &&
(isDefined(selectedNestedField)
? isFieldWidgetEligibleNestedParentField(selectedField) &&
isOneToManyRelationField(selectedNestedField)
: isOneToManyRelationField(selectedField));
const shouldRegenerateRelationTableView =
currentDisplayMode === FieldDisplayMode.TABLE &&
isSelectingDifferentField &&
selectedField?.type === FieldMetadataType.RELATION &&
selectedField.relation?.type === RelationType.ONE_TO_MANY &&
nextDisplayMode === FieldDisplayMode.TABLE &&
(isSelectingDifferentChain || !isDefined(currentViewId)) &&
isValidRelationChain &&
isDefined(widgetId) &&
isDefined(targetObjectMetadataId) &&
isDefined(targetFieldMetadataId);
isDefined(inverseFieldMetadataId);
const regeneratedRelationTableViewId = shouldRegenerateRelationTableView
? addDraftViewForFieldRelationTableWidget(
? addDraftViewForFieldRelationTableWidget({
widgetId,
targetObjectMetadataId,
targetFieldMetadataId,
)
inverseFieldMetadataId,
relationTargetFieldMetadataId,
})
: undefined;
if (isDefined(regeneratedRelationTableViewId)) {
return { viewId: regeneratedRelationTableViewId };
}
if (isSelectingDifferentField && isDefined(currentViewId)) {
if (isSelectingDifferentChain && isDefined(currentViewId)) {
return { viewId: undefined };
}
@@ -1,9 +1,13 @@
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isAdvancedRelationFieldMetadataItem } from '@/object-record/utils/isAdvancedRelationFieldMetadataItem';
import { isFieldWidgetEligibleNestedParentField } from '@/page-layout/widgets/field/utils/isFieldWidgetEligibleNestedParentField';
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
import { type FieldConfiguration } from '@/page-layout/types/FieldConfiguration';
import { useResolveFieldWidgetRelationTableViewIdChange } from '@/page-layout/widgets/record-table/hooks/useResolveFieldWidgetRelationTableViewIdChange';
import { useFieldWidgetEligibleFields } from '@/page-layout/widgets/field/hooks/useFieldWidgetEligibleFields';
import { getFieldWidgetEligibleNestedFields } from '@/page-layout/widgets/field/utils/getFieldWidgetEligibleNestedFields';
import {
getFieldWidgetDefaultDisplayMode,
isDisplayModeValidForFieldType,
@@ -11,6 +15,7 @@ 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 { FieldWidgetNestedFieldDropdownContent } from '@/side-panel/pages/page-layout/components/dropdown-content/FieldWidgetNestedFieldDropdownContent';
import {
StyledPageLayoutDropdownContentContainer,
StyledPageLayoutDropdownMenuItemsContainer,
@@ -21,6 +26,7 @@ import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
@@ -29,11 +35,13 @@ import { useMemo, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/icon';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { type FieldConfiguration } from '~/generated-metadata/graphql';
import { FieldDisplayMode } from '~/generated-metadata/graphql';
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
export const FieldWidgetFieldDropdownContent = () => {
const [searchQuery, setSearchQuery] = useState('');
const [drillInFieldMetadataItem, setDrillInFieldMetadataItem] =
useState<FieldMetadataItem | null>(null);
const { pageLayoutId, objectNameSingular } =
usePageLayoutIdFromContextStore();
@@ -45,6 +53,8 @@ export const FieldWidgetFieldDropdownContent = () => {
| undefined;
const currentFieldMetadataId = fieldConfiguration?.fieldMetadataId;
const currentNestedRelationFieldMetadataId =
fieldConfiguration?.nestedRelationFieldMetadataId;
const allFieldWidgetFieldMetadataItems =
useFieldWidgetEligibleFields(objectNameSingular);
@@ -98,6 +108,9 @@ export const FieldWidgetFieldDropdownContent = () => {
const { closeDropdown } = useCloseDropdown();
const { setSelectedItemId, resetSelectedItem } =
useSelectableList(dropdownId);
const { getIcon } = useIcons();
const searchableFieldMetadataItems = [
@@ -114,15 +127,75 @@ export const FieldWidgetFieldDropdownContent = () => {
const { fieldMetadataItem: currentFieldMetadataItem } =
useFieldMetadataItemById(currentFieldMetadataId ?? '');
const handleSelectField = (fieldMetadataId: string) => {
const selectedField = allFieldWidgetFieldMetadataItems.find(
(field) => field.id === fieldMetadataId,
);
const nestedFieldCandidatesByFieldId = useMemo(() => {
const candidatesByFieldId = new Map<string, FieldMetadataItem[]>();
for (const fieldMetadataItem of allFieldWidgetFieldMetadataItems) {
if (!isFieldWidgetEligibleNestedParentField(fieldMetadataItem)) {
continue;
}
const relationTargetObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.id ===
fieldMetadataItem.relation?.targetObjectMetadata.id,
);
if (!isDefined(relationTargetObjectMetadataItem)) {
continue;
}
const nestedFieldCandidates = getFieldWidgetEligibleNestedFields(
relationTargetObjectMetadataItem,
);
if (nestedFieldCandidates.length > 0) {
candidatesByFieldId.set(fieldMetadataItem.id, nestedFieldCandidates);
}
}
return candidatesByFieldId;
}, [allFieldWidgetFieldMetadataItems, objectMetadataItems]);
// Keyboard focus carries over between the browse list and the drill-in
// submenu since both share the dropdown's selectable list instance. Align
// it on the checked option when entering the submenu, and back on the
// parent row when leaving, so Enter never activates a stale row.
const handleDrillIn = (fieldMetadataItem: FieldMetadataItem) => {
setDrillInFieldMetadataItem(fieldMetadataItem);
const isCheckedNestedFieldInCandidates =
currentFieldMetadataId === fieldMetadataItem.id &&
isDefined(currentNestedRelationFieldMetadataId) &&
(nestedFieldCandidatesByFieldId.get(fieldMetadataItem.id) ?? []).some(
(nestedFieldMetadataItem) =>
nestedFieldMetadataItem.id === currentNestedRelationFieldMetadataId,
);
if (isCheckedNestedFieldInCandidates) {
setSelectedItemId(currentNestedRelationFieldMetadataId);
} else {
resetSelectedItem();
}
};
const handleDrillOut = (fieldMetadataItem: FieldMetadataItem) => {
setDrillInFieldMetadataItem(null);
setSelectedItemId(fieldMetadataItem.id);
};
const isSelectingDifferentChain = (
fieldMetadataId: string,
nestedRelationFieldMetadataId: string | null,
) =>
currentFieldMetadataId !== fieldMetadataId ||
(currentNestedRelationFieldMetadataId ?? null) !==
nestedRelationFieldMetadataId;
const handleSelectField = (selectedField: FieldMetadataItem) => {
const currentDisplayMode = fieldConfiguration?.fieldDisplayMode;
const needsDisplayModeSwitch =
isDefined(selectedField) &&
isDefined(currentDisplayMode) &&
!isDisplayModeValidForFieldType(
selectedField.type,
@@ -130,31 +203,34 @@ export const FieldWidgetFieldDropdownContent = () => {
selectedField.relation?.type,
);
const isSelectingDifferentField =
currentFieldMetadataId !== fieldMetadataId;
const nextDisplayMode = needsDisplayModeSwitch
? getFieldWidgetDefaultDisplayMode(selectedField.type)
: currentDisplayMode;
const relationTableViewIdChange =
resolveFieldWidgetRelationTableViewIdChange({
selectedField,
currentDisplayMode,
isSelectingDifferentField,
nextDisplayMode,
isSelectingDifferentChain: isSelectingDifferentChain(
selectedField.id,
null,
),
widgetId: widgetInEditMode?.id,
currentViewId: fieldConfiguration?.viewId,
});
updateCurrentWidgetConfig({
configToUpdate: {
fieldMetadataId,
fieldMetadataId: selectedField.id,
nestedRelationFieldMetadataId: null,
...relationTableViewIdChange,
...(needsDisplayModeSwitch && {
fieldDisplayMode: getFieldWidgetDefaultDisplayMode(
selectedField.type,
),
fieldDisplayMode: nextDisplayMode,
}),
},
});
if (isDefined(widgetInEditMode) && isDefined(selectedField)) {
if (isDefined(widgetInEditMode)) {
updatePageLayoutWidget(widgetInEditMode.id, {
title: selectedField.label,
});
@@ -163,6 +239,63 @@ export const FieldWidgetFieldDropdownContent = () => {
closeDropdown();
};
const handleSelectNestedField = (
parentFieldMetadataItem: FieldMetadataItem,
nestedFieldMetadataItem: FieldMetadataItem,
) => {
// A nested relation widget always renders as an embedded view, so the
// effective display mode is TABLE regardless of the current one.
const relationTableViewIdChange =
resolveFieldWidgetRelationTableViewIdChange({
selectedField: parentFieldMetadataItem,
selectedNestedField: nestedFieldMetadataItem,
nextDisplayMode: FieldDisplayMode.TABLE,
isSelectingDifferentChain: isSelectingDifferentChain(
parentFieldMetadataItem.id,
nestedFieldMetadataItem.id,
),
widgetId: widgetInEditMode?.id,
currentViewId: fieldConfiguration?.viewId,
});
updateCurrentWidgetConfig({
configToUpdate: {
fieldMetadataId: parentFieldMetadataItem.id,
nestedRelationFieldMetadataId: nestedFieldMetadataItem.id,
fieldDisplayMode: FieldDisplayMode.TABLE,
...relationTableViewIdChange,
},
});
if (isDefined(widgetInEditMode)) {
updatePageLayoutWidget(widgetInEditMode.id, {
title: `${parentFieldMetadataItem.label}${nestedFieldMetadataItem.label}`,
});
}
closeDropdown();
};
if (isDefined(drillInFieldMetadataItem)) {
return (
<FieldWidgetNestedFieldDropdownContent
drillInFieldMetadataItem={drillInFieldMetadataItem}
nestedFieldCandidates={
nestedFieldCandidatesByFieldId.get(drillInFieldMetadataItem.id) ?? []
}
checkedItemId={
currentFieldMetadataId === drillInFieldMetadataItem.id
? (currentNestedRelationFieldMetadataId ??
drillInFieldMetadataItem.id)
: undefined
}
onBack={() => handleDrillOut(drillInFieldMetadataItem)}
onSelectField={handleSelectField}
onSelectNestedField={handleSelectNestedField}
/>
);
}
return (
<StyledPageLayoutDropdownContentContainer>
<DropdownMenuSearchInput
@@ -179,29 +312,42 @@ export const FieldWidgetFieldDropdownContent = () => {
focusId={dropdownId}
selectableItemIdArray={availableFields.map((field) => field.id)}
>
{availableFields.map((fieldMetadataItem) => (
<SelectableListItem
key={fieldMetadataItem.id}
itemId={fieldMetadataItem.id}
onEnter={() => {
handleSelectField(fieldMetadataItem.id);
}}
>
<MenuItemSelect
text={fieldMetadataItem.label}
selected={currentFieldMetadataId === fieldMetadataItem.id}
focused={selectedItemId === fieldMetadataItem.id}
LeftIcon={getIcon(
currentFieldMetadataId === fieldMetadataItem.id
? currentFieldMetadataItem?.icon
: fieldMetadataItem.icon,
)}
onClick={() => {
handleSelectField(fieldMetadataItem.id);
}}
/>
</SelectableListItem>
))}
{availableFields.map((fieldMetadataItem) => {
const hasNestedFieldCandidates = nestedFieldCandidatesByFieldId.has(
fieldMetadataItem.id,
);
const handleClick = hasNestedFieldCandidates
? () => handleDrillIn(fieldMetadataItem)
: () => handleSelectField(fieldMetadataItem);
return (
<SelectableListItem
key={fieldMetadataItem.id}
itemId={fieldMetadataItem.id}
onEnter={handleClick}
>
<MenuItemSelect
text={fieldMetadataItem.label}
// Rows opening a submenu never show the checkmark: the
// selected chain is only visible inside the submenu, like
// the chart group by field selection.
selected={
!hasNestedFieldCandidates &&
currentFieldMetadataId === fieldMetadataItem.id
}
focused={selectedItemId === fieldMetadataItem.id}
LeftIcon={getIcon(
currentFieldMetadataId === fieldMetadataItem.id
? currentFieldMetadataItem?.icon
: fieldMetadataItem.icon,
)}
hasSubMenu={hasNestedFieldCandidates}
onClick={handleClick}
/>
</SelectableListItem>
);
})}
</SelectableList>
</StyledPageLayoutDropdownMenuItemsContainer>
</StyledPageLayoutDropdownContentContainer>
@@ -1,7 +1,10 @@
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { isFieldMetadataItemAvailableAsCalendarField } from '@/object-record/record-calendar/utils/isFieldMetadataItemAvailableAsCalendarField';
import { type FieldConfiguration } from '@/page-layout/types/FieldConfiguration';
import { getFieldWidgetAvailableDisplayModes } from '@/page-layout/widgets/field/utils/getFieldWidgetDisplayModeConfig';
import { getFieldWidgetRelationTraversal } from '@/page-layout/widgets/field/utils/getFieldWidgetRelationTraversal';
import { resolveFieldWidgetNestedRelation } from '@/page-layout/widgets/field/utils/resolveFieldWidgetNestedRelation';
import { useAddDraftViewForFieldRelationTableWidget } from '@/page-layout/widgets/record-table/hooks/useAddDraftViewForFieldRelationTableWidget';
import {
type RecordTableWidgetLayoutViewType,
@@ -32,11 +35,7 @@ import {
IconTable,
} from 'twenty-ui/icon';
import { MenuItemSelect } from 'twenty-ui/navigation';
import {
FieldDisplayMode,
ViewType,
type FieldConfiguration,
} from '~/generated-metadata/graphql';
import { FieldDisplayMode, ViewType } from '~/generated-metadata/graphql';
const DISPLAY_MODE_ICONS: Record<FieldDisplayMode, IconComponent> = {
[FieldDisplayMode.FIELD]: IconListDetails,
@@ -63,12 +62,29 @@ export const FieldWidgetLayoutDropdownContent = () => {
const currentDisplayMode = fieldConfiguration?.fieldDisplayMode;
const currentFieldMetadataId = fieldConfiguration?.fieldMetadataId;
const currentNestedRelationFieldMetadataId =
fieldConfiguration?.nestedRelationFieldMetadataId;
const currentViewId = fieldConfiguration?.viewId ?? null;
const { fieldMetadataItem } = useFieldMetadataItemById(
currentFieldMetadataId ?? '',
);
const { objectMetadataItems } = useObjectMetadataItems();
const resolvedNestedRelation = resolveFieldWidgetNestedRelation({
objectMetadataItems,
relationTargetObjectMetadataId:
fieldMetadataItem?.relation?.targetObjectMetadata.id,
nestedRelationFieldMetadataId: currentNestedRelationFieldMetadataId,
});
// Gate on the configured id, not on resolution success: a widget whose
// second hop was deleted must not fall back to first-hop behavior.
const isNestedRelationWidget = isDefined(
currentNestedRelationFieldMetadataId,
);
const availableDisplayModes = fieldMetadataItem
? getFieldWidgetAvailableDisplayModes(
fieldMetadataItem.type,
@@ -76,23 +92,40 @@ export const FieldWidgetLayoutDropdownContent = () => {
)
: [FieldDisplayMode.FIELD];
const inlineDisplayModes = availableDisplayModes.filter(
(displayMode) => displayMode !== FieldDisplayMode.TABLE,
);
// A nested relation widget only makes sense as an embedded view: inline
// display modes would render the first hop's relation field, contradicting
// the widget's two-hop title.
const inlineDisplayModes = isNestedRelationWidget
? []
: availableDisplayModes.filter(
(displayMode) => displayMode !== FieldDisplayMode.TABLE,
);
const hasEmbeddedViewLayouts = availableDisplayModes.includes(
FieldDisplayMode.TABLE,
);
const targetObjectMetadataId =
fieldMetadataItem?.relation?.targetObjectMetadata.id;
const inverseFieldMetadataId =
fieldMetadataItem?.relation?.targetFieldMetadata.id;
// A configured but unresolvable second hop yields no traversal at all, so a
// stale nested widget cannot fall back to scoping by its first hop.
const relationTraversal =
isNestedRelationWidget && !isDefined(resolvedNestedRelation)
? undefined
: getFieldWidgetRelationTraversal({
sourceFieldMetadataItem: fieldMetadataItem,
nestedRelationFieldMetadataItem:
resolvedNestedRelation?.nestedRelationFieldMetadataItem,
});
const { objectMetadataItems } = useObjectMetadataItems();
const targetObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItemToFind) =>
objectMetadataItemToFind.id === targetObjectMetadataId,
);
const targetObjectMetadataId = relationTraversal?.targetObjectMetadataId;
const inverseFieldMetadataId = relationTraversal?.inverseFieldMetadataId;
const relationTargetFieldMetadataId =
relationTraversal?.relationTargetFieldMetadataId ?? null;
const targetObjectMetadataItem = isNestedRelationWidget
? resolvedNestedRelation?.nestedRelationTargetObjectMetadataItem
: objectMetadataItems.find(
(objectMetadataItemToFind) =>
objectMetadataItemToFind.id === targetObjectMetadataId,
);
const defaultGroupByFieldMetadataItem =
(targetObjectMetadataItem?.readableFields ?? []).find(
@@ -171,11 +204,12 @@ export const FieldWidgetLayoutDropdownContent = () => {
isDefined(targetObjectMetadataId) &&
isDefined(inverseFieldMetadataId)
) {
const viewId = addDraftViewForFieldRelationTableWidget(
widgetInEditMode.id,
const viewId = addDraftViewForFieldRelationTableWidget({
widgetId: widgetInEditMode.id,
targetObjectMetadataId,
inverseFieldMetadataId,
);
relationTargetFieldMetadataId,
});
updateCurrentWidgetConfig({
configToUpdate: {
@@ -0,0 +1,105 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import {
StyledPageLayoutDropdownContentContainer,
StyledPageLayoutDropdownMenuItemsContainer,
} from '@/side-panel/pages/page-layout/components/dropdown-content/PageLayoutDropdownContentContainer';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { IconChevronLeft, useIcons } from 'twenty-ui/icon';
import { MenuItemSelect } from 'twenty-ui/navigation';
type FieldWidgetNestedFieldDropdownContentProps = {
drillInFieldMetadataItem: FieldMetadataItem;
nestedFieldCandidates: FieldMetadataItem[];
checkedItemId: string | undefined;
onBack: () => void;
onSelectField: (fieldMetadataItem: FieldMetadataItem) => void;
onSelectNestedField: (
parentFieldMetadataItem: FieldMetadataItem,
nestedFieldMetadataItem: FieldMetadataItem,
) => void;
};
export const FieldWidgetNestedFieldDropdownContent = ({
drillInFieldMetadataItem,
nestedFieldCandidates,
checkedItemId,
onBack,
onSelectField,
onSelectNestedField,
}: FieldWidgetNestedFieldDropdownContentProps) => {
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
const selectedItemId = useAtomComponentStateValue(
selectedItemIdComponentState,
dropdownId,
);
const { getIcon } = useIcons();
const renderOption = (
fieldMetadataItem: FieldMetadataItem,
onSelect: () => void,
) => (
<SelectableListItem
key={fieldMetadataItem.id}
itemId={fieldMetadataItem.id}
onEnter={onSelect}
>
<MenuItemSelect
text={fieldMetadataItem.label}
selected={checkedItemId === fieldMetadataItem.id}
focused={selectedItemId === fieldMetadataItem.id}
LeftIcon={getIcon(fieldMetadataItem.icon)}
onClick={onSelect}
/>
</SelectableListItem>
);
return (
<StyledPageLayoutDropdownContentContainer>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={onBack}
Icon={IconChevronLeft}
/>
}
>
{drillInFieldMetadataItem.label}
</DropdownMenuHeader>
<StyledPageLayoutDropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={[
drillInFieldMetadataItem.id,
...nestedFieldCandidates.map((field) => field.id),
]}
>
{renderOption(drillInFieldMetadataItem, () =>
onSelectField(drillInFieldMetadataItem),
)}
<DropdownMenuSeparator />
{nestedFieldCandidates.map((nestedFieldMetadataItem) =>
renderOption(nestedFieldMetadataItem, () =>
onSelectNestedField(
drillInFieldMetadataItem,
nestedFieldMetadataItem,
),
),
)}
</SelectableList>
</StyledPageLayoutDropdownMenuItemsContainer>
</StyledPageLayoutDropdownContentContainer>
);
};
@@ -1,7 +1,10 @@
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { CommandMenuItemDropdown } from '@/command-menu/components/CommandMenuItemDropdown';
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { type FieldConfiguration } from '@/page-layout/types/FieldConfiguration';
import { getWidgetConfigurationViewId } from '@/page-layout/utils/getWidgetConfigurationViewId';
import { resolveFieldWidgetNestedRelation } from '@/page-layout/widgets/field/utils/resolveFieldWidgetNestedRelation';
import { useRecordTableWidgetViewFieldItems } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetViewFieldItems';
import { useRecordTableWidgetViewForDisplay } from '@/page-layout/widgets/record-table/hooks/useRecordTableWidgetViewForDisplay';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
@@ -36,7 +39,6 @@ import {
FeatureFlagKey,
FieldDisplayMode,
ViewType,
type FieldConfiguration,
} from '~/generated-metadata/graphql';
const StyledContainer = styled.div`
@@ -68,6 +70,8 @@ export const SidePanelRecordPageFieldSettings = () => {
| undefined;
const currentFieldMetadataId = fieldConfiguration?.fieldMetadataId;
const currentNestedRelationFieldMetadataId =
fieldConfiguration?.nestedRelationFieldMetadataId;
const currentDisplayMode = fieldConfiguration?.fieldDisplayMode;
const currentViewId = isDefined(widgetInEditMode)
? getWidgetConfigurationViewId(widgetInEditMode.configuration)
@@ -76,6 +80,8 @@ export const SidePanelRecordPageFieldSettings = () => {
const { fieldMetadataItem: currentFieldMetadataItem } =
useFieldMetadataItemById(currentFieldMetadataId ?? '');
const { objectMetadataItems } = useObjectMetadataItems();
const { recordTableWidgetViewFieldItems } =
useRecordTableWidgetViewFieldItems({
viewId: currentViewId ?? '',
@@ -83,11 +89,22 @@ export const SidePanelRecordPageFieldSettings = () => {
pageLayoutId,
});
const resolvedNestedRelation = resolveFieldWidgetNestedRelation({
objectMetadataItems,
relationTargetObjectMetadataId:
currentFieldMetadataItem?.relation?.targetObjectMetadata.id,
nestedRelationFieldMetadataId: currentNestedRelationFieldMetadataId,
});
// A relation field widget in table display mode embeds a widget view scoped to
// the current record's related records; its source object is the relation
// target, not the record page's own object.
const targetObjectMetadataId =
currentFieldMetadataItem?.relation?.targetObjectMetadata.id;
// target (or the nested relation target two hops away), not the record
// page's own object. A configured but unresolvable nested relation keeps
// the target undefined so the terminal view's settings stay hidden instead
// of being edited against the first hop's object.
const targetObjectMetadataId = isDefined(currentNestedRelationFieldMetadataId)
? resolvedNestedRelation?.nestedRelationTargetObjectMetadataItem.id
: currentFieldMetadataItem?.relation?.targetObjectMetadata.id;
const { view: embeddedWidgetView } = useRecordTableWidgetViewForDisplay({
viewId: currentViewId ?? '',
@@ -128,7 +145,10 @@ export const SidePanelRecordPageFieldSettings = () => {
);
};
const fieldLabel = currentFieldMetadataItem?.label ?? '';
const baseFieldLabel = currentFieldMetadataItem?.label ?? '';
const fieldLabel = isDefined(resolvedNestedRelation)
? `${baseFieldLabel}${resolvedNestedRelation.nestedRelationFieldMetadataItem.label}`
: baseFieldLabel;
const displayModeLabels: Record<string, string> = {
[FieldDisplayMode.FIELD]: t`Field`,
@@ -326,8 +326,13 @@ export const fromPageLayoutWidgetConfigurationToUniversalConfiguration = ({
}
case WidgetConfigurationType.FIELD: {
const { fieldMetadataId, fieldDisplayMode, configurationType, viewId } =
configuration;
const {
fieldMetadataId,
fieldDisplayMode,
configurationType,
viewId,
nestedRelationFieldMetadataId,
} = configuration;
const fieldMetadataUniversalIdentifier =
getFieldMetadataUniversalIdentifier({
@@ -336,6 +341,16 @@ export const fromPageLayoutWidgetConfigurationToUniversalConfiguration = ({
shouldThrowOnMissingIdentifier,
});
const nestedRelationFieldMetadataUniversalIdentifier = isDefined(
nestedRelationFieldMetadataId,
)
? (getFieldMetadataUniversalIdentifier({
fieldMetadataId: nestedRelationFieldMetadataId,
fieldMetadataUniversalIdentifierById,
shouldThrowOnMissingIdentifier,
}) ?? nestedRelationFieldMetadataId)
: undefined;
let viewUniversalIdentifier: string | undefined = undefined;
if (isDefined(viewId)) {
@@ -358,6 +373,12 @@ export const fromPageLayoutWidgetConfigurationToUniversalConfiguration = ({
fieldMetadataId: fieldMetadataUniversalIdentifier ?? fieldMetadataId,
fieldDisplayMode,
viewId: viewUniversalIdentifier,
...(isDefined(nestedRelationFieldMetadataUniversalIdentifier)
? {
nestedRelationFieldMetadataId:
nestedRelationFieldMetadataUniversalIdentifier,
}
: {}),
};
}
@@ -35,4 +35,9 @@ export class FieldConfigurationDTO implements FieldConfiguration {
@IsOptional()
@IsUUID()
viewId?: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsUUID()
nestedRelationFieldMetadataId?: string | null;
}
@@ -29,6 +29,7 @@ import {
import { type AllPageLayoutWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/types/all-page-layout-widget-configuration.type';
import { fromFlatPageLayoutWidgetToPageLayoutWidgetDto } from 'src/engine/metadata-modules/page-layout-widget/utils/from-flat-page-layout-widget-to-page-layout-widget-dto.util';
import { validateChartConfigurationFieldReferencesOrThrow } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-chart-configuration-field-references.util';
import { validateFieldConfigurationNestedRelationOrThrow } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-field-configuration-nested-relation.util';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
import { DashboardSyncService } from 'src/modules/dashboard-sync/services/dashboard-sync.service';
@@ -121,7 +122,7 @@ export class PageLayoutWidgetService {
}
}
private async validateChartFieldReferences({
private async validateConfigurationFieldReferences({
configuration,
objectMetadataId,
widgetTitle,
@@ -147,6 +148,13 @@ export class PageLayoutWidgetService {
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
validateFieldConfigurationNestedRelationOrThrow({
widgetConfiguration: configuration,
widgetObjectMetadataId: objectMetadataId,
widgetTitle,
flatFieldMetadataMaps,
});
}
async findByPageLayoutTabId({
@@ -259,7 +267,7 @@ export class PageLayoutWidgetService {
});
if (isDefined(createInput.configuration)) {
await this.validateChartFieldReferences({
await this.validateConfigurationFieldReferences({
configuration: createInput.configuration,
objectMetadataId: createInput.objectMetadataId ?? null,
widgetTitle: createInput.title,
@@ -378,12 +386,12 @@ export class PageLayoutWidgetService {
workspaceCustomFlatApplication.universalIdentifier,
});
const shouldValidateChartFields =
const shouldValidateConfigurationFields =
isConfigurationBeingUpdated ||
Object.prototype.hasOwnProperty.call(updateData, 'objectMetadataId') ||
Object.prototype.hasOwnProperty.call(updateData, 'type');
if (shouldValidateChartFields) {
if (shouldValidateConfigurationFields) {
const isObjectMetadataIdBeingUpdated =
Object.prototype.hasOwnProperty.call(updateData, 'objectMetadataId');
const effectiveConfiguration = isConfigurationBeingUpdated
@@ -396,7 +404,7 @@ export class PageLayoutWidgetService {
processedUpdateData.title ?? existingWidget.title;
if (isDefined(effectiveConfiguration)) {
await this.validateChartFieldReferences({
await this.validateConfigurationFieldReferences({
configuration: effectiveConfiguration,
objectMetadataId: effectiveObjectMetadataId,
widgetTitle: effectiveWidgetTitle,
@@ -0,0 +1,303 @@
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { FieldDisplayMode } from 'src/engine/metadata-modules/page-layout-widget/enums/field-display-mode.enum';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { type AllPageLayoutWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/types/all-page-layout-widget-configuration.type';
import { validateFieldConfigurationNestedRelationOrThrow } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-field-configuration-nested-relation.util';
const COMPANY_OBJECT_ID = 'company-object-id';
const PERSON_OBJECT_ID = 'person-object-id';
const OPPORTUNITY_OBJECT_ID = 'opportunity-object-id';
const PEOPLE_FIELD_ID = 'company-people-field-id';
const OWNED_OPPORTUNITIES_FIELD_ID = 'person-owned-opportunities-field-id';
const PERSON_COMPANY_FIELD_ID = 'person-company-field-id';
const COMPANY_NAME_FIELD_ID = 'company-name-field-id';
const peopleField = getFlatFieldMetadataMock({
id: PEOPLE_FIELD_ID,
universalIdentifier: 'company-people-field-ui',
objectMetadataId: COMPANY_OBJECT_ID,
type: FieldMetadataType.RELATION,
name: 'people',
label: 'People',
settings: { relationType: RelationType.ONE_TO_MANY },
relationTargetObjectMetadataId: PERSON_OBJECT_ID,
});
const ownedOpportunitiesField = getFlatFieldMetadataMock({
id: OWNED_OPPORTUNITIES_FIELD_ID,
universalIdentifier: 'person-owned-opportunities-field-ui',
objectMetadataId: PERSON_OBJECT_ID,
type: FieldMetadataType.RELATION,
name: 'ownedOpportunities',
label: 'Owned opportunities',
settings: { relationType: RelationType.ONE_TO_MANY },
relationTargetObjectMetadataId: OPPORTUNITY_OBJECT_ID,
});
const personCompanyField = getFlatFieldMetadataMock({
id: PERSON_COMPANY_FIELD_ID,
universalIdentifier: 'person-company-field-ui',
objectMetadataId: PERSON_OBJECT_ID,
type: FieldMetadataType.RELATION,
name: 'company',
label: 'Company',
settings: {
relationType: RelationType.MANY_TO_ONE,
joinColumnName: 'companyId',
},
relationTargetObjectMetadataId: COMPANY_OBJECT_ID,
});
const companyNameField = getFlatFieldMetadataMock({
id: COMPANY_NAME_FIELD_ID,
universalIdentifier: 'company-name-field-ui',
objectMetadataId: COMPANY_OBJECT_ID,
type: FieldMetadataType.TEXT,
name: 'name',
label: 'Name',
settings: null,
});
const buildFlatFieldMetadataMaps = (
fields: FlatFieldMetadata[],
): FlatEntityMaps<FlatFieldMetadata> => ({
byUniversalIdentifier: Object.fromEntries(
fields.map((field) => [field.universalIdentifier, field]),
),
universalIdentifierById: Object.fromEntries(
fields.map((field) => [field.id, field.universalIdentifier]),
),
universalIdentifiersByApplicationId: {},
});
const flatFieldMetadataMaps = buildFlatFieldMetadataMaps([
peopleField,
ownedOpportunitiesField,
personCompanyField,
companyNameField,
]);
const buildFieldConfiguration = (
overrides: Partial<{
fieldMetadataId: string;
nestedRelationFieldMetadataId: string | null;
fieldDisplayMode: FieldDisplayMode;
}> = {},
): AllPageLayoutWidgetConfiguration =>
({
configurationType: WidgetConfigurationType.FIELD,
fieldMetadataId: PEOPLE_FIELD_ID,
fieldDisplayMode: FieldDisplayMode.TABLE,
nestedRelationFieldMetadataId: OWNED_OPPORTUNITIES_FIELD_ID,
...overrides,
}) as AllPageLayoutWidgetConfiguration;
describe('validateFieldConfigurationNestedRelationOrThrow', () => {
it('should pass for a valid one-to-many chain', () => {
expect(() =>
validateFieldConfigurationNestedRelationOrThrow({
widgetConfiguration: buildFieldConfiguration(),
widgetObjectMetadataId: COMPANY_OBJECT_ID,
flatFieldMetadataMaps,
}),
).not.toThrow();
});
it('should ignore non-FIELD configurations', () => {
expect(() =>
validateFieldConfigurationNestedRelationOrThrow({
widgetConfiguration: {
configurationType: WidgetConfigurationType.IFRAME,
} as AllPageLayoutWidgetConfiguration,
widgetObjectMetadataId: COMPANY_OBJECT_ID,
flatFieldMetadataMaps,
}),
).not.toThrow();
});
it('should ignore FIELD configurations without a nested relation', () => {
expect(() =>
validateFieldConfigurationNestedRelationOrThrow({
widgetConfiguration: buildFieldConfiguration({
nestedRelationFieldMetadataId: null,
}),
widgetObjectMetadataId: COMPANY_OBJECT_ID,
flatFieldMetadataMaps,
}),
).not.toThrow();
});
it('should throw when a nested relation is combined with an inline display mode', () => {
expect(() =>
validateFieldConfigurationNestedRelationOrThrow({
widgetConfiguration: buildFieldConfiguration({
fieldDisplayMode: FieldDisplayMode.FIELD,
}),
widgetObjectMetadataId: COMPANY_OBJECT_ID,
flatFieldMetadataMaps,
}),
).toThrow(/fieldDisplayMode/);
});
it('should throw when the source field does not exist', () => {
expect(() =>
validateFieldConfigurationNestedRelationOrThrow({
widgetConfiguration: buildFieldConfiguration({
fieldMetadataId: 'unknown-field-id',
}),
widgetObjectMetadataId: COMPANY_OBJECT_ID,
flatFieldMetadataMaps,
}),
).toThrow(/not found/);
});
it('should pass for a valid many-to-one first hop chain', () => {
expect(() =>
validateFieldConfigurationNestedRelationOrThrow({
widgetConfiguration: buildFieldConfiguration({
fieldMetadataId: PERSON_COMPANY_FIELD_ID,
nestedRelationFieldMetadataId: PEOPLE_FIELD_ID,
}),
widgetObjectMetadataId: PERSON_OBJECT_ID,
flatFieldMetadataMaps,
}),
).not.toThrow();
});
it('should throw when the source field is not a relation', () => {
expect(() =>
validateFieldConfigurationNestedRelationOrThrow({
widgetConfiguration: buildFieldConfiguration({
fieldMetadataId: COMPANY_NAME_FIELD_ID,
}),
widgetObjectMetadataId: COMPANY_OBJECT_ID,
flatFieldMetadataMaps,
}),
).toThrow(/one-to-many or many-to-one/);
});
it('should throw when the source field belongs to another object', () => {
expect(() =>
validateFieldConfigurationNestedRelationOrThrow({
widgetConfiguration: buildFieldConfiguration(),
widgetObjectMetadataId: PERSON_OBJECT_ID,
flatFieldMetadataMaps,
}),
).toThrow(/does not belong to the widget object/);
});
it('should throw when the nested field does not exist', () => {
expect(() =>
validateFieldConfigurationNestedRelationOrThrow({
widgetConfiguration: buildFieldConfiguration({
nestedRelationFieldMetadataId: 'unknown-nested-field-id',
}),
widgetObjectMetadataId: COMPANY_OBJECT_ID,
flatFieldMetadataMaps,
}),
).toThrow(/not found/);
});
it('should throw when the nested field is not a one-to-many relation', () => {
expect(() =>
validateFieldConfigurationNestedRelationOrThrow({
widgetConfiguration: buildFieldConfiguration({
nestedRelationFieldMetadataId: PERSON_COMPANY_FIELD_ID,
}),
widgetObjectMetadataId: COMPANY_OBJECT_ID,
flatFieldMetadataMaps,
}),
).toThrow(/one-to-many/);
});
it('should throw when the source field is a junction many-to-one relation', () => {
const junctionSourceField = getFlatFieldMetadataMock({
id: 'company-junction-source-field-id',
universalIdentifier: 'company-junction-source-field-ui',
objectMetadataId: COMPANY_OBJECT_ID,
type: FieldMetadataType.RELATION,
name: 'primaryAgreement',
label: 'Primary agreement',
settings: {
relationType: RelationType.MANY_TO_ONE,
junctionTargetFieldId: 'junction-target-field-id',
},
relationTargetObjectMetadataId: PERSON_OBJECT_ID,
});
expect(() =>
validateFieldConfigurationNestedRelationOrThrow({
widgetConfiguration: buildFieldConfiguration({
fieldMetadataId: junctionSourceField.id,
}),
widgetObjectMetadataId: COMPANY_OBJECT_ID,
flatFieldMetadataMaps: buildFlatFieldMetadataMaps([
junctionSourceField,
ownedOpportunitiesField,
]),
}),
).toThrow(/one-to-many or many-to-one/);
});
it('should throw when the nested field is a junction relation', () => {
const junctionField = getFlatFieldMetadataMock({
id: 'person-junction-field-id',
universalIdentifier: 'person-junction-field-ui',
objectMetadataId: PERSON_OBJECT_ID,
type: FieldMetadataType.RELATION,
name: 'petCareAgreements',
label: 'Pet care agreements',
settings: {
relationType: RelationType.ONE_TO_MANY,
junctionTargetFieldId: 'junction-target-field-id',
},
relationTargetObjectMetadataId: OPPORTUNITY_OBJECT_ID,
});
expect(() =>
validateFieldConfigurationNestedRelationOrThrow({
widgetConfiguration: buildFieldConfiguration({
nestedRelationFieldMetadataId: junctionField.id,
}),
widgetObjectMetadataId: COMPANY_OBJECT_ID,
flatFieldMetadataMaps: buildFlatFieldMetadataMaps([
peopleField,
ownedOpportunitiesField,
junctionField,
]),
}),
).toThrow(/one-to-many/);
});
it('should throw when the nested field does not belong to the relation target', () => {
const opportunityStagesField = getFlatFieldMetadataMock({
id: 'opportunity-stages-field-id',
universalIdentifier: 'opportunity-stages-field-ui',
objectMetadataId: OPPORTUNITY_OBJECT_ID,
type: FieldMetadataType.RELATION,
name: 'stages',
label: 'Stages',
settings: { relationType: RelationType.ONE_TO_MANY },
relationTargetObjectMetadataId: COMPANY_OBJECT_ID,
});
expect(() =>
validateFieldConfigurationNestedRelationOrThrow({
widgetConfiguration: buildFieldConfiguration({
nestedRelationFieldMetadataId: opportunityStagesField.id,
}),
widgetObjectMetadataId: COMPANY_OBJECT_ID,
flatFieldMetadataMaps: buildFlatFieldMetadataMaps([
peopleField,
ownedOpportunitiesField,
opportunityStagesField,
]),
}),
).toThrow(/does not belong to the relation target/);
});
});
@@ -0,0 +1,155 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { FieldDisplayMode } from 'src/engine/metadata-modules/page-layout-widget/enums/field-display-mode.enum';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import {
PageLayoutWidgetException,
PageLayoutWidgetExceptionCode,
} from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget.exception';
import { type AllPageLayoutWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/types/all-page-layout-widget-configuration.type';
import { findActiveFlatFieldMetadataById } from 'src/engine/metadata-modules/page-layout-widget/utils/find-active-flat-field-metadata-by-id.util';
const buildNestedRelationValidationException = ({
message,
userFriendlyMessage,
widgetTitle,
}: {
message: string;
userFriendlyMessage: MessageDescriptor;
widgetTitle?: string | null;
}): PageLayoutWidgetException => {
const prefix = isDefined(widgetTitle) ? `Widget "${widgetTitle}": ` : '';
return new PageLayoutWidgetException(
prefix + message,
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
{ userFriendlyMessage },
);
};
// Junction relation fields also carry ONE_TO_MANY metadata but are rendered
// through a dedicated junction path, so they are not valid nested hops.
const isPlainOneToManyRelationFlatFieldMetadata = (
field: FlatFieldMetadata,
): boolean =>
isFlatFieldMetadataOfType(field, FieldMetadataType.RELATION) &&
field.settings.relationType === RelationType.ONE_TO_MANY &&
!isNonEmptyString(field.settings.junctionTargetFieldId);
// The first hop can also be many-to-one: the widget then scopes the terminal
// view directly by the single intermediate record the current record points
// at, instead of traversing the relation.
const isPlainRelationFlatFieldMetadata = (field: FlatFieldMetadata): boolean =>
isFlatFieldMetadataOfType(field, FieldMetadataType.RELATION) &&
(field.settings.relationType === RelationType.ONE_TO_MANY ||
field.settings.relationType === RelationType.MANY_TO_ONE) &&
!isNonEmptyString(field.settings.junctionTargetFieldId);
export const validateFieldConfigurationNestedRelationOrThrow = ({
widgetConfiguration,
widgetObjectMetadataId,
widgetTitle,
flatFieldMetadataMaps,
}: {
widgetConfiguration?: AllPageLayoutWidgetConfiguration | null;
widgetObjectMetadataId?: string | null;
widgetTitle?: string | null;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): void => {
if (
!isDefined(widgetConfiguration) ||
widgetConfiguration.configurationType !== WidgetConfigurationType.FIELD
) {
return;
}
const { fieldMetadataId, nestedRelationFieldMetadataId, fieldDisplayMode } =
widgetConfiguration;
if (!isDefined(nestedRelationFieldMetadataId)) {
return;
}
const invalidNestedRelation = (
message: string,
userFriendlyMessage: MessageDescriptor,
) =>
buildNestedRelationValidationException({
message,
userFriendlyMessage,
widgetTitle,
});
// A nested widget lists the second hop through an embedded view, so any
// inline display mode would render the first hop's relation field instead.
if (fieldDisplayMode !== FieldDisplayMode.TABLE) {
throw invalidNestedRelation(
`nestedRelationFieldMetadataId requires fieldDisplayMode "${FieldDisplayMode.TABLE}", got "${fieldDisplayMode}".`,
msg`A nested relation widget must use the table layout.`,
);
}
const sourceField = findActiveFlatFieldMetadataById(
fieldMetadataId,
flatFieldMetadataMaps,
);
if (!isDefined(sourceField)) {
throw invalidNestedRelation(
`fieldMetadataId "${fieldMetadataId}" not found.`,
msg`The field configured for this widget could not be found.`,
);
}
if (!isPlainRelationFlatFieldMetadata(sourceField)) {
throw invalidNestedRelation(
`nestedRelationFieldMetadataId requires "${sourceField.label}" to be a one-to-many or many-to-one relation field.`,
msg`${sourceField.label} must be a one-to-many or many-to-one relation field.`,
);
}
if (
isDefined(widgetObjectMetadataId) &&
sourceField.objectMetadataId !== widgetObjectMetadataId
) {
throw invalidNestedRelation(
`fieldMetadataId "${fieldMetadataId}" does not belong to the widget object.`,
msg`${sourceField.label} does not belong to this widget's object.`,
);
}
const nestedField = findActiveFlatFieldMetadataById(
nestedRelationFieldMetadataId,
flatFieldMetadataMaps,
);
if (!isDefined(nestedField)) {
throw invalidNestedRelation(
`nestedRelationFieldMetadataId "${nestedRelationFieldMetadataId}" not found.`,
msg`The nested relation field configured for this widget could not be found.`,
);
}
if (!isPlainOneToManyRelationFlatFieldMetadata(nestedField)) {
throw invalidNestedRelation(
`nestedRelationFieldMetadataId "${nestedField.label}" must be a one-to-many relation field.`,
msg`${nestedField.label} must be a one-to-many relation field.`,
);
}
if (
nestedField.objectMetadataId !== sourceField.relationTargetObjectMetadataId
) {
throw invalidNestedRelation(
`nestedRelationFieldMetadataId "${nestedField.label}" does not belong to the relation target of "${sourceField.label}".`,
msg`${nestedField.label} does not belong to the object ${sourceField.label} points to.`,
);
}
};
@@ -26,6 +26,7 @@ import { UpdatePageLayoutTabWithWidgetsInput } from 'src/engine/metadata-modules
import { UpdatePageLayoutWidgetWithIdInput } from 'src/engine/metadata-modules/page-layout-widget/dtos/inputs/update-page-layout-widget-with-id.input';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { validateChartConfigurationFieldReferencesOrThrow } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-chart-configuration-field-references.util';
import { validateFieldConfigurationNestedRelationOrThrow } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-field-configuration-nested-relation.util';
import { UpdatePageLayoutWithTabsInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout-with-tabs.input';
import { PageLayoutDTO } from 'src/engine/metadata-modules/page-layout/dtos/page-layout.dto';
import {
@@ -542,7 +543,7 @@ export class PageLayoutUpdateService {
widgetsToDelete: FlatPageLayoutWidget[];
} {
for (const widgetInput of widgets) {
this.validateChartFieldReferences({
this.validateConfigurationFieldReferences({
widgetInput,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
@@ -847,7 +848,7 @@ export class PageLayoutUpdateService {
);
}
private validateChartFieldReferences({
private validateConfigurationFieldReferences({
widgetInput,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
@@ -867,6 +868,13 @@ export class PageLayoutUpdateService {
flatFieldMetadataMaps,
flatObjectMetadataMaps,
});
validateFieldConfigurationNestedRelationOrThrow({
widgetConfiguration: widgetInput.configuration,
widgetObjectMetadataId: widgetInput.objectMetadataId,
widgetTitle: widgetInput.title,
flatFieldMetadataMaps,
});
}
private collectOrphanedViewIdsFromRemovedWidgets({
@@ -316,6 +316,8 @@ export const fromUniversalConfigurationToFlatPageLayoutWidgetConfiguration = ({
const {
fieldMetadataId: fieldMetadataUniversalIdentifier,
viewId: viewUniversalIdentifier,
nestedRelationFieldMetadataId:
nestedRelationFieldMetadataUniversalIdentifier,
...rest
} = universalConfiguration;
@@ -324,6 +326,16 @@ export const fromUniversalConfigurationToFlatPageLayoutWidgetConfiguration = ({
flatFieldMetadataMaps,
});
const nestedRelationFieldMetadataId = isDefined(
nestedRelationFieldMetadataUniversalIdentifier,
)
? resolveFieldMetadataIdOrThrow({
fieldMetadataUniversalIdentifier:
nestedRelationFieldMetadataUniversalIdentifier,
flatFieldMetadataMaps,
})
: undefined;
let viewId: string | undefined = undefined;
if (isDefined(viewUniversalIdentifier)) {
@@ -342,7 +354,14 @@ export const fromUniversalConfigurationToFlatPageLayoutWidgetConfiguration = ({
viewId = flatView.id;
}
return { ...rest, fieldMetadataId, viewId };
return {
...rest,
fieldMetadataId,
viewId,
...(isDefined(nestedRelationFieldMetadataId)
? { nestedRelationFieldMetadataId }
: {}),
};
}
case WidgetConfigurationType.VIEW:
@@ -101,6 +101,9 @@ export type FieldConfiguration = {
fieldMetadataId: string;
fieldDisplayMode: 'CARD' | 'EDITOR' | 'FIELD' | 'VIEW' | 'TABLE';
viewId?: string;
// One-to-many relation field on the relation target object, to list records
// two relation hops away (e.g. Company -> People -> Owned opportunities)
nestedRelationFieldMetadataId?: string | null;
};
export type FieldsConfiguration = {