Add a Table display mode to relation field widgets (#20929)

## Context

Adds a new Table layout to the FIELD widget for to-many relation fields.
On a record page, a relation can now be displayed as a full record table
(the same component used for record indexes and dashboard table widgets)
scoped to the records related to the current record.



https://github.com/user-attachments/assets/320b24dc-f019-4d0e-bc71-3e64d032d75a



https://github.com/user-attachments/assets/2f6d4f8e-de26-4fc1-ae12-c9b9c19654dc



https://github.com/user-attachments/assets/3fb6d512-f83c-4818-823e-46ad2644fbc2
This commit is contained in:
Weiko
2026-05-27 14:13:58 +02:00
committed by GitHub
parent fc3004b2f6
commit 2f358a1775
47 changed files with 695 additions and 158 deletions
@@ -0,0 +1,8 @@
import { createContext } from 'react';
export type RecordFilterValueDependenciesContextValue = {
currentRecordId?: string;
};
export const RecordFilterValueDependenciesContext =
createContext<RecordFilterValueDependenciesContextValue>({});
@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { useContext, useMemo } from 'react';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { RecordFilterValueDependenciesContext } from '@/object-record/record-filter/contexts/RecordFilterValueDependenciesContext';
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type RecordFilterValueDependencies } from 'twenty-shared/types';
@@ -13,12 +14,15 @@ export const useFilterValueDependencies = (): {
const { userTimezone } = useUserTimezone();
const { currentRecordId } = useContext(RecordFilterValueDependenciesContext);
const filterValueDependencies = useMemo(
() => ({
currentWorkspaceMemberId,
currentRecordId,
timeZone: userTimezone,
}),
[currentWorkspaceMemberId, userTimezone],
[currentWorkspaceMemberId, currentRecordId, userTimezone],
);
return { filterValueDependencies };
@@ -74,7 +74,13 @@ export const useLoadRecordIndexStates = () => {
const { setRecordGroupsFromViewGroups } = useSetRecordGroups();
const loadRecordIndexStates = useCallback(
(view: View, objectMetadataItem: EnrichedObjectMetadataItem) => {
(
view: View,
objectMetadataItem: EnrichedObjectMetadataItem,
options?: { skipGlobalIndexStates?: boolean },
) => {
const skipGlobalIndexStates = options?.skipGlobalIndexStates ?? false;
const activeFieldMetadataItems = objectMetadataItem.fields.filter(
(field) => field.isActive && !isHiddenSystemField(field),
);
@@ -207,12 +213,16 @@ export const useLoadRecordIndexStates = () => {
store.set(
atom(null, (get, batchSet) => {
const existingFieldDefs = get(recordIndexFieldDefinitionsState.atom);
if (!isDeeplyEqual(existingFieldDefs, newFieldDefinitions)) {
batchSet(
if (!skipGlobalIndexStates) {
const existingFieldDefs = get(
recordIndexFieldDefinitionsState.atom,
newFieldDefinitions,
);
if (!isDeeplyEqual(existingFieldDefs, newFieldDefinitions)) {
batchSet(
recordIndexFieldDefinitionsState.atom,
newFieldDefinitions,
);
}
}
for (const viewField of view.viewFields) {
@@ -261,13 +271,15 @@ export const useLoadRecordIndexStates = () => {
filters: contextStoreFilters,
});
batchSet(recordIndexViewTypeState.atom, view.type);
batchSet(recordIndexOpenRecordInState.atom, view.openRecordIn);
if (!skipGlobalIndexStates) {
batchSet(recordIndexViewTypeState.atom, view.type);
batchSet(recordIndexOpenRecordInState.atom, view.openRecordIn);
batchSet(
recordIndexCalendarFieldMetadataIdState.atom,
view.calendarFieldMetadataId ?? null,
);
batchSet(
recordIndexCalendarFieldMetadataIdState.atom,
view.calendarFieldMetadataId ?? null,
);
}
batchSet(
recordIndexShouldHideEmptyRecordGroupsAtom,
@@ -8,10 +8,19 @@ import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledTableContainer = styled.div`
border: 1px solid ${themeCssVariables.border.color.light};
border-radius: ${themeCssVariables.border.radius.sm};
min-height: 0;
overflow: hidden;
`;
export const RecordTableWidget = () => {
type RecordTableWidgetProps = {
isReadOnly?: boolean;
isEmptyStateHidden?: boolean;
};
export const RecordTableWidget = ({
isReadOnly = true,
isEmptyStateHidden = false,
}: RecordTableWidgetProps) => {
const { objectNameSingular, recordIndexId, viewBarInstanceId } =
useRecordIndexContextOrThrow();
@@ -19,6 +28,8 @@ export const RecordTableWidget = () => {
<>
<RecordTableWidgetSetReadOnlyColumnHeadersEffect
recordTableId={recordIndexId}
isReadOnly={isReadOnly}
isEmptyStateHidden={isEmptyStateHidden}
/>
<RecordIndexTableContainerEffect />
<StyledTableContainer>
@@ -1,19 +1,22 @@
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { isRecordTableCheckboxColumnHiddenComponentState } from '@/object-record/record-table/states/isRecordTableCheckboxColumnHiddenComponentState';
import { isRecordTableDragColumnHiddenComponentState } from '@/object-record/record-table/states/isRecordTableDragColumnHiddenComponentState';
import { isRecordTableCellsNonEditableComponentState } from '@/object-record/record-table/states/isRecordTableCellsNonEditableComponentState';
import { isRecordTableColumnHeadersReadOnlyComponentState } from '@/object-record/record-table/states/isRecordTableColumnHeadersReadOnlyComponentState';
import { isRecordTableColumnResizableComponentState } from '@/object-record/record-table/states/isRecordTableColumnResizableComponentState';
import { isRecordTableEmptyStateHiddenComponentState } from '@/object-record/record-table/states/isRecordTableEmptyStateHiddenComponentState';
import { useStore } from 'jotai';
import { useEffect } from 'react';
export const RecordTableWidgetSetReadOnlyColumnHeadersEffect = ({
recordTableId,
isReadOnly = true,
isEmptyStateHidden = false,
}: {
recordTableId: string;
isReadOnly?: boolean;
isEmptyStateHidden?: boolean;
}) => {
const store = useStore();
const isPageLayoutInEditMode = useIsPageLayoutInEditMode();
useEffect(() => {
store.set(
@@ -41,16 +44,23 @@ export const RecordTableWidgetSetReadOnlyColumnHeadersEffect = ({
isRecordTableColumnResizableComponentState.atomFamily({
instanceId: recordTableId,
}),
isPageLayoutInEditMode,
true,
);
store.set(
isRecordTableCellsNonEditableComponentState.atomFamily({
instanceId: recordTableId,
}),
true,
isReadOnly,
);
}, [store, recordTableId, isPageLayoutInEditMode]);
store.set(
isRecordTableEmptyStateHiddenComponentState.atomFamily({
instanceId: recordTableId,
}),
isEmptyStateHidden,
);
}, [store, recordTableId, isReadOnly, isEmptyStateHidden]);
return null;
};
@@ -75,7 +75,9 @@ export const RecordTableWidgetViewLoadEffect = ({
return;
}
loadRecordIndexStates(currentView, objectMetadataItem);
loadRecordIndexStates(currentView, objectMetadataItem, {
skipGlobalIndexStates: true,
});
setLastLoadedRecordTableWidgetViewId({
viewId,
@@ -12,6 +12,7 @@ import { RecordTableScrollToFocusedRowEffect } from '@/object-record/record-tabl
import { RECORD_TABLE_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-table/constants/RecordTableClickOutsideListenerId';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { isRecordTableEmptyStateHiddenComponentState } from '@/object-record/record-table/states/isRecordTableEmptyStateHiddenComponentState';
import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
@@ -46,6 +47,11 @@ export const RecordTable = () => {
recordTableId,
);
const isRecordTableEmptyStateHidden = useAtomComponentStateValue(
isRecordTableEmptyStateHiddenComponentState,
recordTableId,
);
const hasRecordGroups = useAtomComponentSelectorValue(
hasRecordGroupsComponentSelector,
recordTableId,
@@ -83,7 +89,8 @@ export const RecordTable = () => {
)}
{isRecordTableInitialLoading &&
isEmpty(visibleRecordFields) ? null : recordTableIsEmpty &&
!hasRecordGroups ? (
!hasRecordGroups &&
!isRecordTableEmptyStateHidden ? (
<RecordTableEmpty tableBodyRef={tableBodyRef} />
) : (
<RecordTableContent
@@ -1,11 +1,13 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { RecordFilterValueDependenciesContext } from '@/object-record/record-filter/contexts/RecordFilterValueDependenciesContext';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { buildRecordInputFromFilter } from '@/object-record/record-table/utils/buildRecordInputFromFilter';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useContext } from 'react';
export const useBuildRecordInputFromFilters = ({
objectMetadataItem,
@@ -21,11 +23,14 @@ export const useBuildRecordInputFromFilters = ({
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const { currentRecordId } = useContext(RecordFilterValueDependenciesContext);
const buildRecordInputFromFilters = (): Partial<ObjectRecord> => {
return buildRecordInputFromFilter({
currentRecordFilters,
objectMetadataItem,
currentWorkspaceMember: currentWorkspaceMember ?? undefined,
currentRecordId,
});
};
@@ -0,0 +1,9 @@
import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const isRecordTableEmptyStateHiddenComponentState =
createAtomComponentState<boolean>({
key: 'isRecordTableEmptyStateHiddenComponentState',
defaultValue: false,
componentInstanceContext: RecordTableComponentInstanceContext,
});
@@ -16,10 +16,12 @@ export const buildRecordInputFromFilter = ({
currentRecordFilters,
objectMetadataItem,
currentWorkspaceMember,
currentRecordId,
}: {
currentRecordFilters: RecordFilter[];
objectMetadataItem: EnrichedObjectMetadataItem;
currentWorkspaceMember?: CurrentWorkspaceMember;
currentRecordId?: string;
}): Partial<ObjectRecord> => {
const recordInput: Partial<ObjectRecord> = {};
@@ -38,6 +40,7 @@ export const buildRecordInputFromFilter = ({
options: fieldMetadataItem.options ?? undefined,
relationType: fieldMetadataItem.relation?.type,
currentWorkspaceMember: currentWorkspaceMember ?? undefined,
currentRecordId,
label: filter.label,
});
@@ -35,6 +35,7 @@ type ValueComputeContext = {
options?: FilterOption[] | null;
relationType?: RelationType;
currentWorkspaceMember?: CurrentWorkspaceMember;
currentRecordId?: string;
label?: string;
};
@@ -231,14 +232,19 @@ const computeValueFromFilterRelation = (
relationType?: RelationType,
currentWorkspaceMember?: CurrentWorkspaceMember,
label?: string,
currentRecordId?: string,
) => {
switch (operand) {
case ViewFilterOperand.IS: {
const parsedValue = parseJson<{
isCurrentWorkspaceMemberSelected: boolean;
isCurrentRecordSelected: boolean;
selectedRecordIds: string[];
}>(value);
if (relationType === RelationType.MANY_TO_ONE) {
if (parsedValue?.isCurrentRecordSelected) {
return currentRecordId;
}
if (label === 'Assignee') {
return parsedValue?.isCurrentWorkspaceMemberSelected
? currentWorkspaceMember?.id
@@ -341,6 +347,7 @@ const VALUE_HANDLER_REGISTRY: Partial<Record<FieldMetadataType, ValueHandler>> =
value,
relationType,
currentWorkspaceMember,
currentRecordId,
label,
}) =>
computeValueFromFilterRelation(
@@ -349,6 +356,7 @@ const VALUE_HANDLER_REGISTRY: Partial<Record<FieldMetadataType, ValueHandler>> =
relationType,
currentWorkspaceMember,
label,
currentRecordId,
),
[FieldMetadataType.TS_VECTOR]: ({ operand, value }) =>
computeValueFromFilterTSVector(
@@ -504,12 +512,14 @@ export const buildValueFromFilter = ({
options,
relationType,
currentWorkspaceMember,
currentRecordId,
label,
}: {
filter: RecordFilter;
options?: FilterOption[] | null;
relationType?: RelationType;
currentWorkspaceMember?: CurrentWorkspaceMember;
currentRecordId?: string;
label?: string;
}) => {
if (isCompositeFieldType(filter.type)) {
@@ -541,6 +551,7 @@ export const buildValueFromFilter = ({
options,
relationType,
currentWorkspaceMember,
currentRecordId,
label,
});
};
@@ -66,15 +66,17 @@ export const RecordTableVirtualizedInitialDataLoadEffect = () => {
return;
}
// Wait for the atomic batch from loadRecordIndexStates to populate
// visibleRecordFields before triggering any fetch. This guard must apply
// to every branch: when the current view is a draft (e.g. an unsaved
// record-table widget view), it is not in the persisted views store, so
// currentView is undefined and the view-change branch below never runs.
if (isEmpty(visibleRecordFields)) {
return;
}
(async () => {
if ((currentView?.id ?? null) !== lastContextStoreVirtualizedViewId) {
// Wait for the atomic batch from loadRecordIndexStates to populate
// visibleRecordFields before triggering a fetch. On the next render
// after the batch, fields will be populated and we'll proceed.
if (isEmpty(visibleRecordFields)) {
return;
}
setLastContextStoreVirtualizedViewId(currentView?.id ?? null);
setLastRecordTableQueryIdentifier(queryIdentifier);
setLastContextStoreVirtualizedVisibleRecordFields(visibleRecordFields);