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">``<img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg">``</a>
This commit is contained in:
@@ -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.
|
||||
|
||||
+3
@@ -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;
|
||||
+14
-4
@@ -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,
|
||||
|
||||
+12
@@ -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,
|
||||
|
||||
+9
@@ -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({
|
||||
|
||||
+43
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+78
@@ -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>
|
||||
);
|
||||
};
|
||||
+52
@@ -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
-1
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
+13
@@ -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 =
|
||||
|
||||
+47
-21
@@ -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`}
|
||||
/>
|
||||
|
||||
+39
-12
@@ -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()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+51
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+13
-4
@@ -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,
|
||||
|
||||
+13
@@ -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);
|
||||
+14
@@ -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);
|
||||
+1
@@ -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;
|
||||
};
|
||||
|
||||
+116
-8
@@ -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>
|
||||
|
||||
+77
@@ -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();
|
||||
});
|
||||
});
|
||||
+99
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+81
@@ -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();
|
||||
});
|
||||
});
|
||||
+10
@@ -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));
|
||||
+54
@@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
+46
@@ -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,
|
||||
};
|
||||
};
|
||||
+10
@@ -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);
|
||||
+15
@@ -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);
|
||||
+73
@@ -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
@@ -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
|
||||
|
||||
+107
@@ -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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
+180
@@ -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 });
|
||||
});
|
||||
});
|
||||
+13
-5
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
+38
-19
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
+184
-38
@@ -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>
|
||||
|
||||
+54
-20
@@ -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: {
|
||||
|
||||
+105
@@ -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>
|
||||
);
|
||||
};
|
||||
+25
-5
@@ -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`,
|
||||
|
||||
+23
-2
@@ -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,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -35,4 +35,9 @@ export class FieldConfigurationDTO implements FieldConfiguration {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
viewId?: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
nestedRelationFieldMetadataId?: string | null;
|
||||
}
|
||||
|
||||
+13
-5
@@ -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,
|
||||
|
||||
+303
@@ -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/);
|
||||
});
|
||||
});
|
||||
+155
@@ -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.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
+10
-2
@@ -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({
|
||||
|
||||
+20
-1
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user