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
@@ -4,7 +4,7 @@ import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEvent
import { AllMetadataName } from '~/generated-metadata/graphql';
export const PageLayoutTabSSEEffect = () => {
const queryId = 'page-layout-tab-metadata-sse-effect';
const queryId = 'page-layout-sse-effect';
const { refreshPageLayouts } = useRefreshPageLayouts();
@@ -4,7 +4,7 @@ import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEvent
import { AllMetadataName } from '~/generated-metadata/graphql';
export const PageLayoutWidgetSSEEffect = () => {
const queryId = 'page-layout-widget-metadata-sse-effect';
const queryId = 'page-layout-widget-sse-effect';
const { refreshPageLayouts } = useRefreshPageLayouts();
@@ -0,0 +1,130 @@
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail';
import { useGetObjectMetadataItemIdFromViewRelationEventDetail } from '@/metadata-store/hooks/useGetObjectMetadataItemIdFromViewRelationEventDetail';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { useRefreshCoreViewsByObjectMetadataId } from '@/views/hooks/useRefreshCoreViewsByObjectMetadataId';
import { type ViewField } from '@/views/types/ViewField';
import { type ViewFilter } from '@/views/types/ViewFilter';
import { type ViewFilterGroup } from '@/views/types/ViewFilterGroup';
import { type ViewSort } from '@/views/types/ViewSort';
import { isDefined } from 'twenty-shared/utils';
import { useDebouncedCallback } from 'use-debounce';
import { AllMetadataName } from '~/generated-metadata/graphql';
export const ViewRelationsSSEEffect = () => {
const { refreshCoreViewsByObjectMetadataId } =
useRefreshCoreViewsByObjectMetadataId();
const { getObjectMetadataItemIdFromViewRelationEventDetail } =
useGetObjectMetadataItemIdFromViewRelationEventDetail();
useListenToEventsForQuery({
queryId: 'view-filters-sse-effect',
operationSignature: {
metadataName: AllMetadataName.viewFilter,
variables: {},
},
});
useListenToEventsForQuery({
queryId: 'view-sorts-sse-effect',
operationSignature: {
metadataName: AllMetadataName.viewSort,
variables: {},
},
});
useListenToEventsForQuery({
queryId: 'view-fields-sse-effect',
operationSignature: {
metadataName: AllMetadataName.viewField,
variables: {},
},
});
useListenToEventsForQuery({
queryId: 'view-filter-groups-sse-effect',
operationSignature: {
metadataName: AllMetadataName.viewFilterGroup,
variables: {},
},
});
// TODO: replace this with detailed SSE optimistic effects
const debouncedRefreshCoreViewsByObjectMetadataId = useDebouncedCallback(
(objectMetadataId: string) => {
refreshCoreViewsByObjectMetadataId(objectMetadataId);
},
500,
{
leading: false,
trailing: true,
},
);
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.viewFilter,
onMetadataOperationBrowserEvent: (
detail: MetadataOperationBrowserEventDetail<ViewFilter>,
) => {
const objectMetadataItemId =
getObjectMetadataItemIdFromViewRelationEventDetail(detail);
if (!isDefined(objectMetadataItemId)) {
return;
}
debouncedRefreshCoreViewsByObjectMetadataId(objectMetadataItemId);
},
});
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.viewSort,
onMetadataOperationBrowserEvent: (
detail: MetadataOperationBrowserEventDetail<ViewSort>,
) => {
const objectMetadataItemId =
getObjectMetadataItemIdFromViewRelationEventDetail(detail);
if (!isDefined(objectMetadataItemId)) {
return;
}
debouncedRefreshCoreViewsByObjectMetadataId(objectMetadataItemId);
},
});
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.viewFilterGroup,
onMetadataOperationBrowserEvent: (
detail: MetadataOperationBrowserEventDetail<ViewFilterGroup>,
) => {
const objectMetadataItemId =
getObjectMetadataItemIdFromViewRelationEventDetail(detail);
if (!isDefined(objectMetadataItemId)) {
return;
}
debouncedRefreshCoreViewsByObjectMetadataId(objectMetadataItemId);
},
});
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.viewField,
onMetadataOperationBrowserEvent: (
detail: MetadataOperationBrowserEventDetail<ViewField>,
) => {
const objectMetadataItemId =
getObjectMetadataItemIdFromViewRelationEventDetail(detail);
if (!isDefined(objectMetadataItemId)) {
return;
}
debouncedRefreshCoreViewsByObjectMetadataId(objectMetadataItemId);
},
});
return null;
};
@@ -1,15 +1,20 @@
import { useListenToMetadataOperationBrowserEvent } from '@/browser-event/hooks/useListenToMetadataOperationBrowserEvent';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { useRefreshCoreViewsByObjectMetadataId } from '@/views/hooks/useRefreshCoreViewsByObjectMetadataId';
import { coreViewsState } from '@/views/states/coreViewState';
import { type CoreViewWithoutRelations } from '@/views/types/CoreViewWithoutRelations';
import { useStore } from 'jotai';
import { AllMetadataName } from '~/generated-metadata/graphql';
import { sleep } from '~/utils/sleep';
export const ViewSSEEffect = () => {
const queryId = 'view-metadata-sse-effect';
const queryId = 'views-sse-effect';
const store = useStore();
const { refreshCoreViewsByObjectMetadataId } =
useRefreshCoreViewsByObjectMetadataId();
useListenToEventsForQuery({
queryId,
operationSignature: {
@@ -20,7 +25,7 @@ export const ViewSSEEffect = () => {
useListenToMetadataOperationBrowserEvent({
metadataName: AllMetadataName.view,
onMetadataOperationBrowserEvent: (eventDetail) => {
onMetadataOperationBrowserEvent: async (eventDetail) => {
switch (eventDetail.operation.type) {
case 'create': {
const createdView = eventDetail.operation
@@ -38,6 +43,11 @@ export const ViewSSEEffect = () => {
viewGroups: [],
},
]);
await sleep(50);
refreshCoreViewsByObjectMetadataId(createdView.objectMetadataId);
break;
}
case 'update': {
@@ -0,0 +1,39 @@
import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail';
import { useGetViewIdFromViewSSERelationEventDetail } from '@/metadata-store/hooks/useGetViewIdFromViewSSERelationEventDetail';
import { useGetViewById } from '@/views/hooks/useGetViewById';
import { type ViewField } from '@/views/types/ViewField';
import { type ViewFilter } from '@/views/types/ViewFilter';
import { type ViewFilterGroup } from '@/views/types/ViewFilterGroup';
import { type ViewSort } from '@/views/types/ViewSort';
import { isDefined } from 'twenty-shared/utils';
export const useGetObjectMetadataItemIdFromViewRelationEventDetail = () => {
const { getViewIdFromViewSSERelationEventDetail } =
useGetViewIdFromViewSSERelationEventDetail();
const { getViewById } = useGetViewById();
const getObjectMetadataItemIdFromViewRelationEventDetail = <
T extends ViewFilter | ViewFilterGroup | ViewField | ViewSort,
>(
eventDetail: MetadataOperationBrowserEventDetail<T>,
) => {
const viewId = getViewIdFromViewSSERelationEventDetail(eventDetail);
if (!isDefined(viewId)) {
return null;
}
const { view } = getViewById(viewId);
if (!isDefined(view)) {
return null;
}
return view.objectMetadataId;
};
return {
getObjectMetadataItemIdFromViewRelationEventDetail,
};
};
@@ -0,0 +1,64 @@
import { type MetadataOperationBrowserEventDetail } from '@/browser-event/types/MetadataOperationBrowserEventDetail';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { coreViewsState } from '@/views/states/coreViewState';
import { type ViewField } from '@/views/types/ViewField';
import { type ViewFilter } from '@/views/types/ViewFilter';
import { type ViewFilterGroup } from '@/views/types/ViewFilterGroup';
import { type ViewSort } from '@/views/types/ViewSort';
import { type Nullable } from 'twenty-shared/types';
import { AllMetadataName } from '~/generated-metadata/graphql';
export const useGetViewIdFromViewSSERelationEventDetail = () => {
const coreViews = useAtomStateValue(coreViewsState);
const getViewIdFromViewSSERelationEventDetail = <
T extends ViewFilter | ViewFilterGroup | ViewField | ViewSort,
>(
eventDetail: MetadataOperationBrowserEventDetail<T>,
) => {
switch (eventDetail.operation.type) {
case 'create': {
return eventDetail.operation.createdRecord?.viewId as Nullable<string>;
}
case 'update': {
return eventDetail.operation.updatedRecord?.viewId as Nullable<string>;
}
case 'delete': {
const deletedRecordId = eventDetail.operation.deletedRecordId;
switch (eventDetail.metadataName) {
case AllMetadataName.viewFilter:
return coreViews.find((view) =>
view.viewFilters.some(
(viewFilter) => viewFilter.id === deletedRecordId,
),
)?.id;
case AllMetadataName.viewField:
return coreViews.find((view) =>
view.viewFields.some(
(viewField) => viewField.id === deletedRecordId,
),
)?.id;
case AllMetadataName.viewFilterGroup:
return coreViews.find((view) =>
view.viewFilterGroups?.some(
(viewFilterGroup) => viewFilterGroup.id === deletedRecordId,
),
)?.id;
case AllMetadataName.viewSort:
return coreViews.find((view) =>
view.viewSorts.some(
(viewSort) => viewSort.id === deletedRecordId,
),
)?.id;
default:
return null;
}
}
}
};
return {
getViewIdFromViewSSERelationEventDetail,
};
};