Added SSE effect for view relations objects (#18386)

This PR adds what is necessary for having SSE working for view relations
: fields, filters, filter groups and sorts.

This should allow to have AI working well while creating views with
detailed filtering and sorting.

## Demo


https://github.com/user-attachments/assets/026c7fb5-8e1a-4498-b7f4-d16993e5a7c4

## Fixes

Also fixed in this PR while working on the filter area : 
- Advanced filter does not update
- Advanced filter sub field selection is broken (due to Jotai migration)
- No view fields when creating a new view
- Error on advanced filter deletion (cascade delete wasn't taken into
account on the frontend)
- Bug advanced filter creation

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Lucas Bordeau
2026-03-05 12:20:33 +01:00
committed by GitHub
parent 57499342f1
commit 1b9d188e4a
22 changed files with 382 additions and 158 deletions
@@ -1,108 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { type ViewFilter } from '@/views/types/ViewFilter';
import { ViewFilterOperand } from 'twenty-shared/types';
import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils';
import { getJestMetadataAndApolloMocksAndActionMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndActionMenuWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
import { useApplyViewFiltersToCurrentRecordFilters } from '@/views/hooks/useApplyViewFiltersToCurrentRecordFilters';
const mockObjectMetadataItemNameSingular = 'company';
describe('useApplyViewFiltersToCurrentRecordFilters', () => {
const mockObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === mockObjectMetadataItemNameSingular,
);
if (!isDefined(mockObjectMetadataItem)) {
throw new Error(
`Missing mock object metadata item with name singular ${mockObjectMetadataItemNameSingular}`,
);
}
const mockFieldMetadataItem = mockObjectMetadataItem.fields[0];
const mockViewFilter: ViewFilter = {
__typename: 'ViewFilter',
id: 'filter-1',
fieldMetadataId: mockFieldMetadataItem.id,
operand: ViewFilterOperand.CONTAINS,
value: 'test',
displayValue: mockFieldMetadataItem.label,
viewFilterGroupId: 'group-1',
positionInViewFilterGroup: 0,
};
it('should apply view filters to current record filters', () => {
const { result } = renderHook(
() => {
const { applyViewFiltersToCurrentRecordFilters } =
useApplyViewFiltersToCurrentRecordFilters();
const currentRecordFilters = useAtomComponentStateValue(
currentRecordFiltersComponentState,
);
return { applyViewFiltersToCurrentRecordFilters, currentRecordFilters };
},
{
wrapper: getJestMetadataAndApolloMocksAndActionMenuWrapper({
apolloMocks: [],
componentInstanceId: 'instanceId',
contextStoreCurrentObjectMetadataNameSingular:
mockObjectMetadataItemNameSingular,
}),
},
);
act(() => {
result.current.applyViewFiltersToCurrentRecordFilters([mockViewFilter]);
});
expect(result.current.currentRecordFilters).toEqual([
{
id: mockViewFilter.id,
fieldMetadataId: mockViewFilter.fieldMetadataId,
value: mockViewFilter.value,
displayValue: mockViewFilter.displayValue,
operand: mockViewFilter.operand,
recordFilterGroupId: mockViewFilter.viewFilterGroupId,
positionInRecordFilterGroup: mockViewFilter.positionInViewFilterGroup,
label: mockFieldMetadataItem.label,
type: getFilterTypeFromFieldType(mockFieldMetadataItem.type),
} satisfies RecordFilter,
]);
});
it('should handle empty view filters array', () => {
const { result } = renderHook(
() => {
const { applyViewFiltersToCurrentRecordFilters } =
useApplyViewFiltersToCurrentRecordFilters();
const currentRecordFilters = useAtomComponentStateValue(
currentRecordFiltersComponentState,
);
return { applyViewFiltersToCurrentRecordFilters, currentRecordFilters };
},
{
wrapper: getJestMetadataAndApolloMocksAndActionMenuWrapper({
apolloMocks: [],
componentInstanceId: 'instanceId',
contextStoreCurrentObjectMetadataNameSingular:
mockObjectMetadataItemNameSingular,
}),
},
);
act(() => {
result.current.applyViewFiltersToCurrentRecordFilters([]);
});
expect(result.current.currentRecordFilters).toEqual([]);
});
});
@@ -1,5 +1,6 @@
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { currentRecordFieldsComponentState } from '@/object-record/record-field/states/currentRecordFieldsComponentState';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { recordIndexShouldHideEmptyRecordGroupsComponentState } from '@/object-record/record-index/states/recordIndexShouldHideEmptyRecordGroupsComponentState';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
@@ -9,6 +10,7 @@ import { coreViewsByObjectMetadataIdFamilySelector } from '@/views/states/select
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { getFilterableFields } from '@/views/utils/getFilterableFields';
import { mapViewFieldToRecordField } from '@/views/utils/mapViewFieldToRecordField';
import { mapViewFilterGroupsToRecordFilterGroups } from '@/views/utils/mapViewFilterGroupsToRecordFilterGroups';
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
import { useCallback } from 'react';
import { isDefined, removePropertiesFromRecord } from 'twenty-shared/utils';
@@ -106,6 +108,27 @@ export const useApplyCoreViewsForObjectMetadataId = () => {
);
}
if (
!isDeeplyEqual(
coreView.viewFilterGroups,
existingView.viewFilterGroups,
)
) {
const view = convertCoreViewToView(coreView);
jotaiStore.set(
currentRecordFilterGroupsComponentState.atomFamily({
instanceId: getRecordIndexIdFromObjectNamePluralAndViewId(
objectMetadataItem.namePlural,
view.id,
),
}),
mapViewFilterGroupsToRecordFilterGroups(
view.viewFilterGroups ?? [],
),
);
}
if (
!isDeeplyEqual(
coreView.viewSorts.map((viewSort) =>
@@ -115,6 +138,7 @@ export const useApplyCoreViewsForObjectMetadataId = () => {
)
) {
const view = convertCoreViewToView(coreView);
jotaiStore.set(
currentRecordSortsComponentState.atomFamily({
instanceId: getRecordIndexIdFromObjectNamePluralAndViewId(
@@ -1,24 +0,0 @@
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { type ViewFilter } from '@/views/types/ViewFilter';
import { useMapViewFiltersToFilters } from './useMapViewFiltersToFilters';
export const useApplyViewFiltersToCurrentRecordFilters = () => {
const setCurrentRecordFilters = useSetAtomComponentState(
currentRecordFiltersComponentState,
);
const { mapViewFiltersToRecordFilters } = useMapViewFiltersToFilters();
const applyViewFiltersToCurrentRecordFilters = (
viewFilters: ViewFilter[],
) => {
const recordFiltersToApply = mapViewFiltersToRecordFilters(viewFilters);
setCurrentRecordFilters(recordFiltersToApply);
};
return {
applyViewFiltersToCurrentRecordFilters,
};
};
@@ -1,12 +1,24 @@
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { useStore } from 'jotai';
import { useCallback } from 'react';
export const useGetViewById = (viewId: string | null) => {
const view = useAtomFamilySelectorValue(coreViewFromViewIdFamilySelector, {
viewId: viewId ?? '',
});
export const useGetViewById = () => {
const store = useStore();
const getViewById = useCallback(
(viewId: string | null) => {
const view = store.get(
coreViewFromViewIdFamilySelector.selectorFamily({
viewId: viewId ?? '',
}),
);
return { view };
},
[store],
);
return {
view: viewId ? view : undefined,
getViewById,
};
};
@@ -1,8 +1,10 @@
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { usePerformViewFilterGroupAPIPersist } from '@/views/hooks/internal/usePerformViewFilterGroupAPIPersist';
import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { coreViewsState } from '@/views/states/coreViewState';
import { getViewFilterGroupsToCreate } from '@/views/utils/getViewFilterGroupsToCreate';
import { getViewFilterGroupsToDelete } from '@/views/utils/getViewFilterGroupsToDelete';
import { getViewFilterGroupsToUpdate } from '@/views/utils/getViewFilterGroupsToUpdate';
@@ -26,6 +28,8 @@ export const useSaveRecordFilterGroupsToViewFilterGroups = () => {
const store = useStore();
const setCoreViews = useSetAtomState(coreViewsState);
const saveRecordFilterGroupsToViewFilterGroups = useCallback(async () => {
if (!canPersistChanges || !isDefined(currentView)) {
return;
@@ -55,6 +59,33 @@ export const useSaveRecordFilterGroupsToViewFilterGroups = () => {
newViewFilterGroups,
);
const viewFiltersToOptimisticallyCascadeDelete =
currentView.viewFilters.filter((viewFilter) =>
viewFilterGroupsToDelete.some(
(viewFilterGroupToDelete) =>
viewFilterGroupToDelete.id === viewFilter.viewFilterGroupId,
),
);
for (const viewFilterToCascadeDelete of viewFiltersToOptimisticallyCascadeDelete) {
setCoreViews((currentCoreViews) => {
const updatedCoreViews = currentCoreViews.map((coreView) => {
if (coreView.id !== currentView.id) {
return coreView;
}
return {
...coreView,
viewFilters: coreView.viewFilters.filter(
(viewFilter) => viewFilter.id !== viewFilterToCascadeDelete.id,
),
};
});
return updatedCoreViews;
});
}
const viewFilterGroupsToUpdate = getViewFilterGroupsToUpdate(
currentViewFilterGroups,
newViewFilterGroups,
@@ -71,13 +102,14 @@ export const useSaveRecordFilterGroupsToViewFilterGroups = () => {
await performViewFilterGroupAPIUpdate(viewFilterGroupsToUpdate);
await performViewFilterGroupAPIDelete(viewFilterGroupIdsToDelete);
}, [
store,
canPersistChanges,
currentView,
store,
currentRecordFilterGroupsCallbackState,
performViewFilterGroupAPICreate,
performViewFilterGroupAPIUpdate,
performViewFilterGroupAPIDelete,
setCoreViews,
]);
return {
@@ -1,8 +1,11 @@
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { usePerformViewFilterAPIPersist } from '@/views/hooks/internal/usePerformViewFilterAPIPersist';
import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { coreViewsState } from '@/views/states/coreViewState';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { getViewFiltersToCreate } from '@/views/utils/getViewFiltersToCreate';
import { getViewFiltersToDelete } from '@/views/utils/getViewFiltersToDelete';
import { getViewFiltersToUpdate } from '@/views/utils/getViewFiltersToUpdate';
@@ -19,7 +22,9 @@ export const useSaveRecordFiltersToViewFilters = () => {
performViewFilterAPIDelete,
} = usePerformViewFilterAPIPersist();
const { currentView } = useGetCurrentViewOnly();
const contextStoreCurrentViewId = useAtomComponentStateValue(
contextStoreCurrentViewIdComponentState,
);
const currentRecordFiltersCallbackState = useAtomComponentStateCallbackState(
currentRecordFiltersComponentState,
@@ -28,6 +33,18 @@ export const useSaveRecordFiltersToViewFilters = () => {
const store = useStore();
const saveRecordFiltersToViewFilters = useCallback(async () => {
const views = store.get(coreViewsState.atom);
const currentCoreView = views.find(
(view) => view.id === contextStoreCurrentViewId,
);
if (!isDefined(currentCoreView)) {
return;
}
const currentView = convertCoreViewToView(currentCoreView);
if (!canPersistChanges || !isDefined(currentView)) {
return;
}
@@ -110,11 +127,11 @@ export const useSaveRecordFiltersToViewFilters = () => {
}, [
store,
canPersistChanges,
currentView,
currentRecordFiltersCallbackState,
performViewFilterAPICreate,
performViewFilterAPIUpdate,
performViewFilterAPIDelete,
contextStoreCurrentViewId,
]);
return {
@@ -0,0 +1,12 @@
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
export const useViewById = (viewId: string | null) => {
const view = useAtomFamilySelectorValue(coreViewFromViewIdFamilySelector, {
viewId: viewId ?? '',
});
return {
view: viewId ? view : undefined,
};
};
@@ -6,6 +6,7 @@ import { type ColumnDefinition } from '@/object-record/record-table/types/Column
export type ViewField = {
__typename: 'ViewField';
id: string;
viewId?: string;
fieldMetadataId: string;
position: number;
isVisible: boolean;