647a6aec58
## 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>
114 lines
4.1 KiB
TypeScript
114 lines
4.1 KiB
TypeScript
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
|
|
import { useGetIsMetadataItemFromStandardApplication } from '@/object-metadata/hooks/useGetIsMetadataItemFromStandardApplication';
|
|
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
|
import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition';
|
|
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
|
|
import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly';
|
|
import { isRecordFieldReadOnly } from '@/object-record/read-only/utils/isRecordFieldReadOnly';
|
|
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
|
|
import { useResolveFieldMetadataIdFromNameOrId } from '@/page-layout/hooks/useResolveFieldMetadataIdFromNameOrId';
|
|
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
|
|
import { isFieldWidget } from '@/page-layout/widgets/field/utils/isFieldWidget';
|
|
import { type WidgetAction } from '@/page-layout/widgets/types/WidgetAction';
|
|
import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId';
|
|
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
import { RelationType } from '~/generated-metadata/graphql';
|
|
|
|
type UseWidgetActionsParams = {
|
|
widget: PageLayoutWidget;
|
|
};
|
|
|
|
export const useWidgetActions = ({
|
|
widget,
|
|
}: UseWidgetActionsParams): WidgetAction[] => {
|
|
const targetRecord = useTargetRecord();
|
|
|
|
const { objectMetadataItem } = useObjectMetadataItem({
|
|
objectNameSingular: targetRecord.targetObjectNameSingular,
|
|
});
|
|
|
|
const fieldMetadataId = isFieldWidget(widget)
|
|
? widget.configuration.fieldMetadataId
|
|
: undefined;
|
|
|
|
const resolvedFieldMetadataId = useResolveFieldMetadataIdFromNameOrId(
|
|
fieldMetadataId ?? '',
|
|
);
|
|
|
|
const { fieldMetadataItem } = useFieldMetadataItemById(
|
|
resolvedFieldMetadataId ?? '',
|
|
);
|
|
|
|
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
|
|
const getIsMetadataItemFromStandardApplication =
|
|
useGetIsMetadataItemFromStandardApplication();
|
|
|
|
const isRecordReadOnly = useIsRecordReadOnly({
|
|
recordId: targetRecord.id,
|
|
objectMetadataId: objectMetadataItem.id,
|
|
});
|
|
|
|
const actions: WidgetAction[] = [];
|
|
|
|
if (
|
|
!isFieldWidget(widget) ||
|
|
!isDefined(fieldMetadataItem) ||
|
|
!fieldMetadataItem.isActive
|
|
) {
|
|
return actions;
|
|
}
|
|
|
|
const fieldDefinition = formatFieldMetadataItemAsColumnDefinition({
|
|
field: fieldMetadataItem,
|
|
position: 0,
|
|
objectMetadataItem,
|
|
showLabel: true,
|
|
labelWidth: 90,
|
|
});
|
|
|
|
const isOneToManyRelation =
|
|
isFieldRelation(fieldDefinition) &&
|
|
fieldDefinition.metadata.relationType === RelationType.ONE_TO_MANY;
|
|
|
|
// "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,
|
|
});
|
|
}
|
|
|
|
const isFieldReadOnly = isRecordFieldReadOnly({
|
|
isRecordReadOnly,
|
|
isSystemObject: objectMetadataItem.isSystem,
|
|
objectPermissions: getObjectPermissionsFromMapByObjectMetadataId({
|
|
objectPermissionsByObjectMetadataId,
|
|
objectMetadataId: objectMetadataItem.id,
|
|
}),
|
|
isFieldFromStandardApplication:
|
|
getIsMetadataItemFromStandardApplication(fieldMetadataItem),
|
|
fieldMetadataItem: {
|
|
id: fieldMetadataItem.id,
|
|
isUIEditable: fieldMetadataItem.isUIEditable ?? true,
|
|
},
|
|
fieldDefinition,
|
|
objectPermissionsByObjectMetadataId,
|
|
});
|
|
|
|
if (!isFieldReadOnly) {
|
|
actions.push({
|
|
id: 'edit',
|
|
position: 1,
|
|
});
|
|
}
|
|
|
|
return actions.sort((a, b) => a.position - b.position);
|
|
};
|