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:
+8
@@ -0,0 +1,8 @@
|
||||
import { createContext } from 'react';
|
||||
|
||||
export type RecordFilterValueDependenciesContextValue = {
|
||||
currentRecordId?: string;
|
||||
};
|
||||
|
||||
export const RecordFilterValueDependenciesContext =
|
||||
createContext<RecordFilterValueDependenciesContextValue>({});
|
||||
+6
-2
@@ -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 };
|
||||
|
||||
+23
-11
@@ -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,
|
||||
|
||||
+12
-1
@@ -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>
|
||||
|
||||
+15
-5
@@ -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;
|
||||
};
|
||||
|
||||
+3
-1
@@ -75,7 +75,9 @@ export const RecordTableWidgetViewLoadEffect = ({
|
||||
return;
|
||||
}
|
||||
|
||||
loadRecordIndexStates(currentView, objectMetadataItem);
|
||||
loadRecordIndexStates(currentView, objectMetadataItem, {
|
||||
skipGlobalIndexStates: true,
|
||||
});
|
||||
|
||||
setLastLoadedRecordTableWidgetViewId({
|
||||
viewId,
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
+5
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+9
@@ -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,
|
||||
});
|
||||
+3
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
+11
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
+9
-7
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user