Compare commits

...

10 Commits

Author SHA1 Message Date
Charles Bochet 50a94f5659 1.5.3 tag 2025-09-10 16:23:00 +02:00
Charles Bochet 38f6ca2afe Implement ViewGroups optimistic rendering (#14388)
# Context

We have recently migrated from workspace.views to core.views. While
doing it, we've lost the optimistic rendering on views in frontend. This
is an issue for viewField and viewGroups that are persisted without
having an intermediate storing layer (viewFilters, viewSorts,
viewFilterGroups are not directly persisted when we do the changes,
therefore we have an underlying layer to keep them and the optimistic
was implemented there already).

ViewFields have been treated in a previous PR, this PR focus on
ViewGroups

## What
Optimistic on ViewGroups

## Other 
+ fix a bug in the sequencing to persist viewFilter + viewFilterGroups
2025-09-10 16:10:25 +02:00
Charles Bochet 2accf0e0c7 Fix view advanced filters broken (#14387)
## Context

We recently migrated from workspaceSchema.views to core.views. While
doing it we've migrated views using 1-5-migrate-views-to-core command
but we forgot to migrate the subFieldName

Closes: https://github.com/twentyhq/twenty/issues/14369
2025-09-10 16:10:15 +02:00
Abdul Rahman f32f1bc1ff fix: agents query runs even when AI feature flag is disabled (#14372) 2025-09-10 16:09:49 +02:00
Charles Bochet 698dc9585f Rename prefetchViewStates in coreViewStates (#14373)
View system used to rely on workspaceSchema views and was loaded in what
we called the "prefetch". We have recently migrated views to
core.coreViews and the states should now be named after coreViews

Fixes: https://github.com/twentyhq/twenty/issues/14349
2025-09-10 16:08:15 +02:00
Etienne fbd4ceb2ca Upsert - fixes (#14358)
- add more explicit error message on upsert conflicts
- increase MAX_RECORDS_QUERY constant
- add soft deleted records when returning upserted records




fixes https://github.com/twentyhq/private-issues/issues/300
2025-09-10 16:06:56 +02:00
Thomas Trompette b1a827e66e Remove is branch enabled feature flag (#14357)
As title
2025-09-10 16:06:33 +02:00
Charles Bochet ac1cfba3b2 DevXP improvements on new views (#14330) 2025-09-10 16:06:06 +02:00
Charles Bochet 914f286a1f Messaging cleaning fixes (#14345) 2025-09-08 00:26:07 +02:00
Charles Bochet 1d9a1c69e4 Improve Messaging Gmail experience (#14342)
In this PR, I'm solving several issues:
1) We were not checking if the currentWorkspaceMember was owning the
message in the thread. It kind of worked before because the case of
shared threads (with shared threads visibility restriction) was not
happening that often. It seems that the bug has always been there
2) Re-implement orphan messages and threads deletion on messageChannel
deletion. We used to brutally look for all orphans, we disabled it last
week because it was too heavy on db. I've re-implemented it more
carefully and "surgically"
3) Gmail sync was not handling folder synced correctly. It was
leveraging labelIds which it shouldn't do (this is a AND AND parameter)
in full sync
4) Added a command to clean orphan message threads manually if needed.
Usually this is done when you remove a messageChannel, or change
blocklist rules but it can be useful to have it to debug
2025-09-07 21:16:28 +02:00
108 changed files with 1339 additions and 940 deletions
@@ -1003,7 +1003,6 @@ export enum FeatureFlagKey {
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
IS_WORKFLOW_BRANCH_ENABLED = 'IS_WORKFLOW_BRANCH_ENABLED',
IS_WORKFLOW_ITERATOR_ENABLED = 'IS_WORKFLOW_ITERATOR_ENABLED',
IS_WORKSPACE_MIGRATION_V2_ENABLED = 'IS_WORKSPACE_MIGRATION_V2_ENABLED'
}
@@ -967,7 +967,6 @@ export enum FeatureFlagKey {
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
IS_WORKFLOW_BRANCH_ENABLED = 'IS_WORKFLOW_BRANCH_ENABLED',
IS_WORKFLOW_ITERATOR_ENABLED = 'IS_WORKFLOW_ITERATOR_ENABLED',
IS_WORKSPACE_MIGRATION_V2_ENABLED = 'IS_WORKSPACE_MIGRATION_V2_ENABLED'
}
@@ -12,12 +12,10 @@ import { DATABASE_TRIGGER_TYPES } from '@/workflow/workflow-trigger/constants/Da
import { OTHER_TRIGGER_TYPES } from '@/workflow/workflow-trigger/constants/OtherTriggerTypes';
import { useUpdateWorkflowVersionTrigger } from '@/workflow/workflow-trigger/hooks/useUpdateWorkflowVersionTrigger';
import { getTriggerDefaultDefinition } from '@/workflow/workflow-trigger/utils/getTriggerDefaultDefinition';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useTheme } from '@emotion/react';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { useIcons } from 'twenty-ui/display';
import { MenuItemCommand } from 'twenty-ui/navigation';
import { FeatureFlagKey } from '~/generated/graphql';
export const CommandMenuWorkflowSelectTriggerTypeContent = ({
workflow,
@@ -35,10 +33,6 @@ export const CommandMenuWorkflowSelectTriggerTypeContent = ({
);
const { openWorkflowEditStepInCommandMenu } = useWorkflowCommandMenu();
const isWorkflowBranchEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED,
);
const handleTriggerTypeClick = ({
type,
defaultLabel,
@@ -54,7 +48,6 @@ export const CommandMenuWorkflowSelectTriggerTypeContent = ({
defaultLabel,
type,
activeNonSystemObjectMetadataItems,
steps: !isWorkflowBranchEnabled ? workflow.currentVersion.steps : [],
}),
);
@@ -2,9 +2,9 @@ import { MainContextStoreProviderEffect } from '@/context-store/components/MainC
import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage';
import { useLastVisitedView } from '@/navigation/hooks/useLastVisitedView';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { prefetchIndexViewIdFromObjectMetadataItemFamilySelector } from '@/prefetch/states/selector/prefetchIndexViewIdFromObjectMetadataItemFamilySelector';
import { AppPath } from '@/types/AppPath';
import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal';
import { coreIndexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/coreIndexViewIdFromObjectMetadataItemFamilySelector';
import { useLocation, useParams, useSearchParams } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
@@ -60,7 +60,7 @@ export const MainContextStoreProvider = () => {
);
const indexViewId = useRecoilValue(
prefetchIndexViewIdFromObjectMetadataItemFamilySelector({
coreIndexViewIdFromObjectMetadataItemFamilySelector({
objectMetadataItemId: objectMetadataItem?.id ?? '',
}),
);
@@ -6,8 +6,8 @@ import { getViewType } from '@/context-store/utils/getViewType';
import { useSetLastVisitedObjectMetadataId } from '@/navigation/hooks/useSetLastVisitedObjectMetadataId';
import { useSetLastVisitedViewForObjectMetadataNamePlural } from '@/navigation/hooks/useSetLastVisitedViewForObjectMetadataNamePlural';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
@@ -53,7 +53,7 @@ export const MainContextStoreProviderEffect = ({
);
const view = useRecoilValue(
prefetchViewFromViewIdFamilySelector({
coreViewFromViewIdFamilySelector({
viewId: viewId ?? '',
}),
);
@@ -2,13 +2,13 @@ import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainCo
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { lastVisitedViewPerObjectMetadataItemState } from '@/navigation/states/lastVisitedViewPerObjectMetadataItemState';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { prefetchViewsFromObjectMetadataItemFamilySelector } from '@/prefetch/states/selector/prefetchViewsFromObjectMetadataItemFamilySelector';
import { AppPath } from '@/types/AppPath';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { NavigationDrawerItemsCollapsableContainer } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItemsCollapsableContainer';
import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem';
import { getNavigationSubItemLeftAdornment } from '@/ui/navigation/navigation-drawer/utils/getNavigationSubItemLeftAdornment';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { coreViewsFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/coreViewsFromObjectMetadataItemFamilySelector';
import { useLocation } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { useIcons } from 'twenty-ui/display';
@@ -23,7 +23,7 @@ export const NavigationDrawerItemForObjectMetadataItem = ({
objectMetadataItem,
}: NavigationDrawerItemForObjectMetadataItemProps) => {
const views = useRecoilValue(
prefetchViewsFromObjectMetadataItemFamilySelector({
coreViewsFromObjectMetadataItemFamilySelector({
objectMetadataItemId: objectMetadataItem.id,
}),
);
@@ -4,12 +4,14 @@ import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useLoadRecordIndexStates } from '@/object-record/record-index/hooks/useLoadRecordIndexStates';
import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState';
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { usePersistViewGroupRecords } from '@/views/hooks/internal/usePersistViewGroupRecords';
import { useUpdateCurrentView } from '@/views/hooks/useUpdateCurrentView';
import { coreViewsState } from '@/views/states/coreViewState';
import { type GraphQLView } from '@/views/types/GraphQLView';
import { type ViewGroup } from '@/views/types/ViewGroup';
import { ViewType, viewTypeIconMapping } from '@/views/types/ViewType';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { convertViewTypeToCore } from '@/views/utils/convertViewTypeToCore';
import { useGetAvailableFieldsForKanban } from '@/views/view-picker/hooks/useGetAvailableFieldsForKanban';
import { useCallback } from 'react';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
@@ -63,7 +65,7 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => {
);
const setAndPersistViewType = useRecoilCallback(
({ snapshot }) =>
({ snapshot, set }) =>
async (viewType: ViewType) => {
const currentViewId = snapshot
.getLoadable(
@@ -73,18 +75,24 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => {
)
.getValue();
const existingCoreViews = snapshot
.getLoadable(coreViewsState)
.getValue();
if (!isDefined(currentViewId)) {
throw new Error('No view id found');
}
const currentView = snapshot
.getLoadable(
prefetchViewFromViewIdFamilySelector({ viewId: currentViewId }),
)
.getValue();
if (!isDefined(currentView)) {
const currentCoreView = existingCoreViews.find(
(coreView) => coreView.id === currentViewId,
);
if (!isDefined(currentCoreView)) {
throw new Error('No current view found');
}
const currentView = convertCoreViewToView(currentCoreView);
const updateCurrentViewParams: Partial<GraphQLView> = {};
updateCurrentViewParams.type = viewType;
@@ -105,6 +113,15 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => {
);
}
setRecordIndexViewType(viewType);
set(coreViewsState, [
...existingCoreViews.filter(
(coreView) => coreView.id !== currentView.id,
),
{
...currentCoreView,
type: convertViewTypeToCore(viewType),
},
]);
if (shouldChangeIcon(currentView.icon, currentView.type)) {
updateCurrentViewParams.icon =
@@ -114,6 +131,16 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => {
}
case ViewType.Table:
setRecordIndexViewType(viewType);
set(coreViewsState, [
...existingCoreViews.filter(
(coreView) => coreView.id !== currentView.id,
),
{
...currentCoreView,
type: convertViewTypeToCore(viewType),
},
]);
if (shouldChangeIcon(currentView.icon, currentView.type)) {
updateCurrentViewParams.icon =
viewTypeIconMapping(viewType).displayName;
@@ -19,11 +19,11 @@ import { getRecordFieldCardRelationPickerDropdownId } from '@/object-record/reco
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { prefetchIndexViewIdFromObjectMetadataItemFamilySelector } from '@/prefetch/states/selector/prefetchIndexViewIdFromObjectMetadataItemFamilySelector';
import { AppPath } from '@/types/AppPath';
import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { coreIndexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/coreIndexViewIdFromObjectMetadataItemFamilySelector';
import { useLingui } from '@lingui/react/macro';
import { ViewFilterOperand } from 'twenty-shared/types';
import { RelationType } from '~/generated-metadata/graphql';
@@ -82,7 +82,7 @@ export const RecordDetailRelationSection = ({
);
const indexViewId = useRecoilValue(
prefetchIndexViewIdFromObjectMetadataItemFamilySelector({
coreIndexViewIdFromObjectMetadataItemFamilySelector({
objectMetadataItemId: relationObjectMetadataItem.id,
}),
);
@@ -26,7 +26,7 @@ export const useChangeRecordFieldVisibility = (
const { saveViewFields } = useSaveCurrentViewFields();
const changeRecordFieldVisibility = ({
const changeRecordFieldVisibility = async ({
fieldMetadataId,
isVisible,
}: {
@@ -56,7 +56,7 @@ export const useChangeRecordFieldVisibility = (
upsertRecordField(recordFieldToUpsert);
saveViewFields([mapRecordFieldToViewField(recordFieldToUpsert)]);
await saveViewFields([mapRecordFieldToViewField(recordFieldToUpsert)]);
} else {
updateRecordField(fieldMetadataId, {
isVisible: shouldShowFieldMetadataItem,
@@ -20,7 +20,7 @@ export const useMoveRecordField = (recordTableId?: string) => {
// because otherwise it will just do nothing while moving left and right of non visible record fields
const moveRecordField = useRecoilCallback(
({ snapshot }) =>
({
async ({
direction,
fieldMetadataItemIdToMove,
}: {
@@ -73,7 +73,7 @@ export const useMoveRecordField = (recordTableId?: string) => {
position: currentRecordFieldNewPosition,
});
saveViewFields([
await saveViewFields([
mapRecordFieldToViewField({
...targetRecordField,
position: targetRecordFieldNewPosition,
@@ -1,8 +1,8 @@
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { useLoadRecordIndexStates } from '@/object-record/record-index/hooks/useLoadRecordIndexStates';
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
@@ -18,7 +18,7 @@ export const RecordIndexLoadBaseOnContextStoreEffect = () => {
);
const view = useRecoilValue(
prefetchViewFromViewIdFamilySelector({
coreViewFromViewIdFamilySelector({
viewId: contextStoreCurrentViewId ?? '',
}),
);
@@ -110,7 +110,12 @@ export const useHandleRecordGroupField = () => {
}
if (viewGroupsToDelete.length > 0) {
await deleteViewGroupRecords(viewGroupsToDelete);
await deleteViewGroupRecords(
viewGroupsToDelete.map((group) => ({
id: group.id,
viewId: view.id,
})),
);
}
},
[
@@ -144,7 +149,12 @@ export const useHandleRecordGroupField = () => {
return;
}
await deleteViewGroupRecords(view.viewGroups);
await deleteViewGroupRecords(
view.viewGroups.map((group) => ({
id: group.id,
viewId: view.id,
})),
);
setRecordGroupsFromViewGroups(view.id, [], objectMetadataItem);
},
@@ -17,7 +17,7 @@ export const useMoveTableColumn = ({ recordTableId }: useRecordTableProps) => {
async (direction: 'left' | 'right', fieldMetadataItemId: string) => {
unfocusRecordTableCell();
moveRecordField({
await moveRecordField({
direction: direction === 'left' ? 'before' : 'after',
fieldMetadataItemIdToMove: fieldMetadataItemId,
});
@@ -98,9 +98,9 @@ export const RecordTableColumnHeadDropdownMenu = ({
moveTableColumn('right', recordField.fieldMetadataItemId);
};
const handleColumnVisibility = () => {
const handleColumnVisibility = async () => {
closeDropdownAndToggleScroll();
changeRecordFieldVisibility({
await changeRecordFieldVisibility({
fieldMetadataId: recordField.fieldMetadataItemId,
isVisible: false,
});
@@ -170,7 +170,7 @@ export const RecordTableColumnHeadDropdownMenu = ({
{canHide && (
<MenuItem
LeftIcon={IconEyeOff}
onClick={handleColumnVisibility}
onClick={async () => await handleColumnVisibility()}
text={t`Hide`}
/>
)}
@@ -31,9 +31,11 @@ export const RecordTableHeaderPlusButtonContent = () => {
useChangeRecordFieldVisibility(recordTableId);
const handleAddColumn = useCallback(
(column: Pick<ColumnDefinition<FieldMetadata>, 'fieldMetadataId'>) => {
async (
column: Pick<ColumnDefinition<FieldMetadata>, 'fieldMetadataId'>,
) => {
closeDropdown();
changeRecordFieldVisibility({ ...column, isVisible: true });
await changeRecordFieldVisibility({ ...column, isVisible: true });
},
[changeRecordFieldVisibility, closeDropdown],
);
@@ -60,8 +62,8 @@ export const RecordTableHeaderPlusButtonContent = () => {
{availableFieldMetadataItemsToShow.map((fieldMetadataItem) => (
<MenuItem
key={fieldMetadataItem.id}
onClick={() =>
handleAddColumn({
onClick={async () =>
await handleAddColumn({
fieldMetadataId: fieldMetadataItem.id,
})
}
@@ -1,19 +0,0 @@
import { coreViewsState } from '@/views/states/coreViewState';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { selectorFamily } from 'recoil';
export const prefetchViewIdsFromObjectMetadataItemFamilySelector =
selectorFamily<string[], { objectMetadataItemId: string }>({
key: 'prefetchViewIdsFromObjectMetadataItemFamilySelector',
get:
({ objectMetadataItemId }) =>
({ get }) => {
const coreViews = get(coreViewsState);
const views = coreViews.map(convertCoreViewToView);
return views
.filter((view) => view.objectMetadataId === objectMetadataItemId)
.map((view) => view.id);
},
});
@@ -54,7 +54,7 @@ export const SettingsRoleAssignment = ({
const { addApiKeyToRoleAndUpdateState, updateApiKeyRoleDraftState } =
useUpdateApiKeyRole(roleId);
const { data: agentsData } = useFindManyAgentsQuery();
const { data: agentsData } = useFindManyAgentsQuery({ skip: !isAiEnabled });
const { data: apiKeysData } = useGetApiKeysQuery();
const { openModal, closeModal } = useModal();
@@ -1,11 +1,11 @@
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { useRecoilComponentFamilyState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { hasInitializedAnyFieldFilterComponentFamilyState } from '@/views/states/hasInitializedAnyFieldFilterComponentFamilyState';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
@@ -18,7 +18,7 @@ export const ViewBarAnyFieldFilterEffect = () => {
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const currentView = useRecoilValue(
prefetchViewFromViewIdFamilySelector({
coreViewFromViewIdFamilySelector({
viewId: currentViewId ?? '',
}),
);
@@ -1,11 +1,11 @@
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { currentRecordFieldsComponentState } from '@/object-record/record-field/states/currentRecordFieldsComponentState';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { useRecoilComponentFamilyState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { hasInitializedCurrentRecordFieldsComponentFamilyState } from '@/views/states/hasInitializedCurrentRecordFieldsComponentFamilyState';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { mapViewFieldToRecordField } from '@/views/utils/mapViewFieldToRecordField';
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
@@ -19,7 +19,7 @@ export const ViewBarRecordFieldEffect = () => {
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const currentView = useRecoilValue(
prefetchViewFromViewIdFamilySelector({
coreViewFromViewIdFamilySelector({
viewId: currentViewId ?? '',
}),
);
@@ -1,12 +1,12 @@
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { useRecoilComponentFamilyState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useMapViewFiltersToFilters } from '@/views/hooks/useMapViewFiltersToFilters';
import { hasInitializedCurrentRecordFiltersComponentFamilyState } from '@/views/states/hasInitializedCurrentRecordFiltersComponentFamilyState';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
@@ -19,7 +19,7 @@ export const ViewBarRecordFilterEffect = () => {
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const currentView = useRecoilValue(
prefetchViewFromViewIdFamilySelector({
coreViewFromViewIdFamilySelector({
viewId: currentViewId ?? '',
}),
);
@@ -1,11 +1,11 @@
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { useRecoilComponentFamilyState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { hasInitializedCurrentRecordFilterGroupsComponentFamilyState } from '@/views/states/hasInitializedCurrentRecordFilterGroupsComponentFamilyState';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { mapViewFilterGroupsToRecordFilterGroups } from '@/views/utils/mapViewFilterGroupsToRecordFilterGroups';
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
@@ -19,7 +19,7 @@ export const ViewBarRecordFilterGroupEffect = () => {
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const currentView = useRecoilValue(
prefetchViewFromViewIdFamilySelector({
coreViewFromViewIdFamilySelector({
viewId: currentViewId ?? '',
}),
);
@@ -1,11 +1,11 @@
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { useRecoilComponentFamilyState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { hasInitializedCurrentRecordSortsComponentFamilyState } from '@/views/states/hasInitializedCurrentRecordSortsComponentFamilyState';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
@@ -19,7 +19,7 @@ export const ViewBarRecordSortEffect = () => {
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const currentView = useRecoilValue(
prefetchViewFromViewIdFamilySelector({
coreViewFromViewIdFamilySelector({
viewId: currentViewId ?? '',
}),
);
@@ -1,35 +1,21 @@
import { useCallback } from 'react';
import { v4 } from 'uuid';
import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect';
import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { CREATE_CORE_VIEW_FIELD } from '@/views/graphql/mutations/createCoreViewField';
import { UPDATE_CORE_VIEW_FIELD } from '@/views/graphql/mutations/updateCoreViewField';
import { useTriggerViewFieldOptimisticEffect } from '@/views/optimistic-effects/hooks/useTriggerViewFieldOptimisticEffect';
import { type GraphQLView } from '@/views/types/GraphQLView';
import { type ViewField } from '@/views/types/ViewField';
import { useApolloClient } from '@apollo/client';
import { isNull } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { type CoreViewField } from '~/generated/graphql';
export const usePersistViewFieldRecords = () => {
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.ViewField,
});
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular: CoreObjectNameSingular.ViewField,
});
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const apolloClient = useApolloClient();
const { triggerViewFieldOptimisticEffect } =
useTriggerViewFieldOptimisticEffect();
const createCoreViewFieldRecords = useCallback(
(
viewFieldsToCreate: Omit<ViewField, 'definition'>[],
@@ -50,28 +36,19 @@ export const usePersistViewFieldRecords = () => {
size: viewField.size,
} satisfies Partial<CoreViewField>,
},
update: (cache, { data }) => {
update: (_cache, { data }) => {
const record = data?.['createCoreViewField'];
if (!record) return;
triggerCreateRecordsOptimisticEffect({
cache,
objectMetadataItem,
recordsToCreate: [record],
objectMetadataItems,
objectPermissionsByObjectMetadataId,
triggerViewFieldOptimisticEffect({
createdViewFields: [record],
});
},
}),
),
);
},
[
apolloClient,
objectMetadataItem,
objectMetadataItems,
objectPermissionsByObjectMetadataId,
],
[apolloClient, triggerViewFieldOptimisticEffect],
);
const updateCoreViewFieldRecords = useCallback(
@@ -91,29 +68,19 @@ export const usePersistViewFieldRecords = () => {
aggregateOperation: viewField.aggregateOperation,
} satisfies Partial<CoreViewField>,
},
update: (cache, { data }) => {
update: (_cache, { data }) => {
const record = data?.['updateCoreViewField'];
if (!isDefined(record)) return;
const cachedRecord = getRecordFromCache<ViewField>(
record.id,
cache,
);
if (isNull(cachedRecord)) return;
triggerUpdateRecordOptimisticEffect({
cache,
objectMetadataItem,
currentRecord: cachedRecord,
updatedRecord: record,
objectMetadataItems,
triggerViewFieldOptimisticEffect({
updatedViewFields: [record],
});
},
}),
),
);
},
[apolloClient, getRecordFromCache, objectMetadataItem, objectMetadataItems],
[apolloClient, triggerViewFieldOptimisticEffect],
);
return {
@@ -3,8 +3,10 @@ import { useCallback } from 'react';
import { CREATE_CORE_VIEW_GROUP } from '@/views/graphql/mutations/createCoreViewGroup';
import { DESTROY_CORE_VIEW_GROUP } from '@/views/graphql/mutations/destroyCoreViewGroup';
import { UPDATE_CORE_VIEW_GROUP } from '@/views/graphql/mutations/updateCoreViewGroup';
import { useTriggerViewGroupOptimisticEffect } from '@/views/optimistic-effects/hooks/useTriggerViewGroupOptimisticEffect';
import { type ViewGroup } from '@/views/types/ViewGroup';
import { useApolloClient } from '@apollo/client';
import { type CoreViewGroup } from '~/generated/graphql';
type CreateViewGroupRecordsArgs = {
viewGroupsToCreate: ViewGroup[];
@@ -14,6 +16,9 @@ type CreateViewGroupRecordsArgs = {
export const usePersistViewGroupRecords = () => {
const apolloClient = useApolloClient();
const { triggerViewGroupOptimisticEffect } =
useTriggerViewGroupOptimisticEffect();
const createCoreViewGroupRecords = useCallback(
({ viewGroupsToCreate, viewId }: CreateViewGroupRecordsArgs) => {
if (viewGroupsToCreate.length === 0) return;
@@ -32,11 +37,19 @@ export const usePersistViewGroupRecords = () => {
position: viewGroup.position,
},
},
update: (_cache, { data }) => {
const record = data?.['createCoreViewGroup'];
if (!record) return;
triggerViewGroupOptimisticEffect({
createdViewGroups: [record],
});
},
}),
),
);
},
[apolloClient],
[apolloClient, triggerViewGroupOptimisticEffect],
);
const updateCoreViewGroupRecords = useCallback(
@@ -44,7 +57,7 @@ export const usePersistViewGroupRecords = () => {
if (!viewGroupsToUpdate.length) return;
const mutationPromises = viewGroupsToUpdate.map((viewGroup) =>
apolloClient.mutate<{ updateCoreViewGroup: ViewGroup }>({
apolloClient.mutate<{ updateCoreViewGroup: CoreViewGroup }>({
mutation: UPDATE_CORE_VIEW_GROUP,
variables: {
id: viewGroup.id,
@@ -55,35 +68,24 @@ export const usePersistViewGroupRecords = () => {
},
// Avoid cache being updated with stale data
fetchPolicy: 'no-cache',
update: (_cache, { data }) => {
const record = data?.['updateCoreViewGroup'];
if (!record) return;
triggerViewGroupOptimisticEffect({
updatedViewGroups: [record],
});
},
}),
);
const mutationResults = await Promise.all(mutationPromises);
// FixMe: Using useUpdateOneRecord hook that call triggerUpdateRecordsOptimisticEffect is actaully causing multiple records to be created
// This is a temporary fix
mutationResults.forEach(({ data }) => {
const record = data?.['updateCoreViewGroup'];
if (!record) return;
apolloClient.cache.modify({
id: apolloClient.cache.identify({
__typename: 'CoreViewGroup',
id: record.id,
}),
fields: {
isVisible: () => record.isVisible,
position: () => record.position,
},
});
});
return Promise.all(mutationPromises);
},
[apolloClient],
[apolloClient, triggerViewGroupOptimisticEffect],
);
const deleteCoreViewGroupRecords = useCallback(
async (viewGroupsToDelete: ViewGroup[]) => {
async (viewGroupsToDelete: Pick<CoreViewGroup, 'id' | 'viewId'>[]) => {
if (!viewGroupsToDelete.length) return;
return Promise.all(
@@ -93,11 +95,16 @@ export const usePersistViewGroupRecords = () => {
variables: {
id: viewGroup.id,
},
update: () => {
triggerViewGroupOptimisticEffect({
deletedViewGroups: [viewGroup],
});
},
}),
),
);
},
[apolloClient],
[apolloClient, triggerViewGroupOptimisticEffect],
);
return {
@@ -1,8 +1,8 @@
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
@@ -20,7 +20,7 @@ export const useApplyCurrentViewAnyFieldFilterToAnyFieldFilter = () => {
() => {
const currentView = snapshot
.getLoadable(
prefetchViewFromViewIdFamilySelector({
coreViewFromViewIdFamilySelector({
viewId: currentViewId ?? '',
}),
)
@@ -1,9 +1,9 @@
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { mapViewFilterGroupsToRecordFilterGroups } from '@/views/utils/mapViewFilterGroupsToRecordFilterGroups';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
@@ -28,7 +28,7 @@ export const useApplyCurrentViewFilterGroupsToCurrentRecordFilterGroups =
() => {
const currentView = snapshot
.getLoadable(
prefetchViewFromViewIdFamilySelector({
coreViewFromViewIdFamilySelector({
viewId: currentViewId ?? '',
}),
)
@@ -1,8 +1,8 @@
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useMapViewFiltersToFilters } from './useMapViewFiltersToFilters';
@@ -23,7 +23,7 @@ export const useApplyCurrentViewFiltersToCurrentRecordFilters = () => {
() => {
const currentView = snapshot
.getLoadable(
prefetchViewFromViewIdFamilySelector({
coreViewFromViewIdFamilySelector({
viewId: currentViewId ?? '',
}),
)
@@ -1,8 +1,8 @@
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
@@ -13,7 +13,7 @@ export const useApplyCurrentViewSortsToCurrentRecordSorts = () => {
);
const currentView = useRecoilValue(
prefetchViewFromViewIdFamilySelector({
coreViewFromViewIdFamilySelector({
viewId: currentViewId ?? '',
}),
);
@@ -4,7 +4,6 @@ import { anyFieldFilterValueComponentState } from '@/object-record/record-filter
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
@@ -15,6 +14,7 @@ import { usePersistViewGroupRecords } from '@/views/hooks/internal/usePersistVie
import { usePersistViewSortRecords } from '@/views/hooks/internal/usePersistViewSortRecords';
import { useRefreshCoreViewsByObjectMetadataId } from '@/views/hooks/useRefreshCoreViewsByObjectMetadataId';
import { isPersistingViewFieldsState } from '@/views/states/isPersistingViewFieldsState';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { type GraphQLView } from '@/views/types/GraphQLView';
import { type ViewGroup } from '@/views/types/ViewGroup';
import { type ViewSort } from '@/views/types/ViewSort';
@@ -97,7 +97,7 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
const sourceView = snapshot
.getLoadable(
prefetchViewFromViewIdFamilySelector({
coreViewFromViewIdFamilySelector({
viewId: currentViewId,
}),
)
@@ -1,5 +1,5 @@
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { useRefreshCoreViewsByObjectMetadataId } from '@/views/hooks/useRefreshCoreViewsByObjectMetadataId';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useDeleteCoreViewMutation } from '~/generated/graphql';
@@ -14,7 +14,7 @@ export const useDeleteView = () => {
async (viewId: string) => {
const currentView = snapshot
.getLoadable(
prefetchViewFromViewIdFamilySelector({
coreViewFromViewIdFamilySelector({
viewId,
}),
)
@@ -1,6 +1,6 @@
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { useRecoilValue } from 'recoil';
@@ -10,7 +10,7 @@ export const useGetCurrentViewOnly = () => {
);
const currentView = useRecoilValue(
prefetchViewFromViewIdFamilySelector({
coreViewFromViewIdFamilySelector({
viewId: currentViewId ?? '',
}),
);
@@ -1,4 +1,4 @@
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { useRecoilCallback } from 'recoil';
export const useGetViewFromPrefetchState = () => {
@@ -7,7 +7,7 @@ export const useGetViewFromPrefetchState = () => {
(viewId: string) => {
const view = snapshot
.getLoadable(
prefetchViewFromViewIdFamilySelector({
coreViewFromViewIdFamilySelector({
viewId: viewId,
}),
)
@@ -3,7 +3,7 @@ import { currentRecordFieldsComponentState } from '@/object-record/record-field/
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { coreViewsByObjectMetadataIdFamilySelector } from '@/views/states/coreViewsByObjectMetadataIdFamilySelector';
import { coreViewsByObjectMetadataIdFamilySelector } from '@/views/states/selectors/coreViewsByObjectMetadataIdFamilySelector';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { getFilterableFieldsWithVectorSearch } from '@/views/utils/getFilterableFieldsWithVectorSearch';
@@ -16,8 +16,8 @@ export const useSaveCurrentViewFiltersAndSorts = () => {
const saveCurrentViewFilterAndSorts = async () => {
await saveRecordSortsToViewSorts();
await saveRecordFiltersToViewFilters();
await saveRecordFilterGroupsToViewFilterGroups();
await saveRecordFiltersToViewFilters();
await saveAnyFieldFilterToView();
};
@@ -1,9 +1,9 @@
import { useRecoilCallback } from 'recoil';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRefreshCoreViewsByObjectMetadataId } from '@/views/hooks/useRefreshCoreViewsByObjectMetadataId';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { type GraphQLView } from '@/views/types/GraphQLView';
import { convertUpdateViewInputToCore } from '@/views/utils/convertUpdateViewInputToCore';
import { isDefined } from 'twenty-shared/utils';
@@ -28,7 +28,7 @@ export const useUpdateCurrentView = () => {
const currentView = snapshot
.getLoadable(
prefetchViewFromViewIdFamilySelector({
coreViewFromViewIdFamilySelector({
viewId: currentViewId ?? '',
}),
)
@@ -1,5 +1,5 @@
import { prefetchIndexViewIdFromObjectMetadataItemFamilySelector } from '@/prefetch/states/selector/prefetchIndexViewIdFromObjectMetadataItemFamilySelector';
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { coreIndexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/coreIndexViewIdFromObjectMetadataItemFamilySelector';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { useRecoilValue } from 'recoil';
export const useViewOrDefaultViewFromPrefetchedViews = ({
@@ -8,13 +8,13 @@ export const useViewOrDefaultViewFromPrefetchedViews = ({
objectMetadataItemId: string;
}) => {
const indexViewId = useRecoilValue(
prefetchIndexViewIdFromObjectMetadataItemFamilySelector({
coreIndexViewIdFromObjectMetadataItemFamilySelector({
objectMetadataItemId,
}),
);
const indexView = useRecoilValue(
prefetchViewFromViewIdFamilySelector({
coreViewFromViewIdFamilySelector({
viewId: indexViewId ?? '',
}),
);
@@ -0,0 +1,144 @@
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { coreViewsState } from '@/views/states/coreViewState';
import { type CoreViewWithRelations } from '@/views/types/CoreViewWithRelations';
import { useApolloClient } from '@apollo/client';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { type CoreViewField } from '~/generated/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useTriggerViewFieldOptimisticEffect = () => {
const apolloClient = useApolloClient();
const cache = apolloClient.cache;
const triggerViewFieldOptimisticEffect = useRecoilCallback(
({ set, snapshot }) =>
({
createdViewFields = [],
updatedViewFields = [],
deletedViewFields = [],
}: {
createdViewFields?: CoreViewField[];
updatedViewFields?: CoreViewField[];
deletedViewFields?: Pick<CoreViewField, 'id' | 'viewId'>[];
}) => {
const coreViews = getSnapshotValue(snapshot, coreViewsState);
let newCoreViews = [...coreViews];
createdViewFields.forEach((createdViewField) => {
cache.modify<CoreViewWithRelations>({
id: cache.identify({
__typename: 'CoreView',
id: createdViewField.viewId,
}),
fields: {
viewFields: (existingViewFields, { toReference }) => [
...(existingViewFields ?? []),
toReference(createdViewField),
],
},
});
const toBeModifiedCoreView = newCoreViews.find(
(coreView) => coreView.id === createdViewField.viewId,
);
if (isDefined(toBeModifiedCoreView)) {
newCoreViews = [
...newCoreViews.filter(
(coreView) => coreView.id !== createdViewField.viewId,
),
{
...toBeModifiedCoreView,
viewFields: [
...toBeModifiedCoreView.viewFields,
createdViewField,
],
},
];
}
});
updatedViewFields.forEach((updatedViewField) => {
cache.modify<CoreViewWithRelations>({
id: cache.identify({
__typename: 'CoreView',
id: updatedViewField.viewId,
}),
fields: {
viewFields: (existingViewFields, { readField, toReference }) =>
existingViewFields.map((viewField) => {
const viewFieldId = readField<string>('id', viewField);
if (viewFieldId === updatedViewField.id) {
return toReference(updatedViewField);
}
return viewField;
}),
},
});
const toBeModifiedCoreView = newCoreViews.find(
(coreView) => coreView.id === updatedViewField.viewId,
);
if (isDefined(toBeModifiedCoreView)) {
newCoreViews = [
...newCoreViews.filter(
(coreView) => coreView.id !== updatedViewField.viewId,
),
{
...toBeModifiedCoreView,
viewFields: [
...toBeModifiedCoreView.viewFields.filter(
(viewField) => viewField.id !== updatedViewField.id,
),
updatedViewField,
],
},
];
}
});
deletedViewFields.forEach(
(deletedViewField: Pick<CoreViewField, 'id' | 'viewId'>) => {
cache.modify<CoreViewWithRelations>({
id: cache.identify({
__typename: 'CoreView',
id: deletedViewField.viewId,
}),
fields: {
viewFields: (existingViewFields, { readField }) =>
existingViewFields.filter(
(viewField) =>
readField('id', viewField) !== deletedViewField.id,
),
},
});
const toBeModifiedCoreView = newCoreViews.find(
(coreView) => coreView.id === deletedViewField.viewId,
);
if (isDefined(toBeModifiedCoreView)) {
newCoreViews = [
...newCoreViews.filter(
(coreView) => coreView.id !== deletedViewField.viewId,
),
{
...toBeModifiedCoreView,
viewFields: toBeModifiedCoreView.viewFields.filter(
(viewField) => viewField.id !== deletedViewField.id,
),
},
];
}
},
);
if (!isDeeplyEqual(coreViews, newCoreViews)) {
set(coreViewsState, newCoreViews);
}
},
[cache],
);
return {
triggerViewFieldOptimisticEffect,
};
};
@@ -0,0 +1,142 @@
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { coreViewsState } from '@/views/states/coreViewState';
import { type CoreViewWithRelations } from '@/views/types/CoreViewWithRelations';
import { useApolloClient } from '@apollo/client';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { type CoreViewGroup } from '~/generated/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useTriggerViewGroupOptimisticEffect = () => {
const apolloClient = useApolloClient();
const cache = apolloClient.cache;
const triggerViewGroupOptimisticEffect = useRecoilCallback(
({ set, snapshot }) =>
({
createdViewGroups = [],
updatedViewGroups = [],
deletedViewGroups = [],
}: {
createdViewGroups?: CoreViewGroup[];
updatedViewGroups?: CoreViewGroup[];
deletedViewGroups?: Pick<CoreViewGroup, 'id' | 'viewId'>[];
}) => {
const coreViews = getSnapshotValue(snapshot, coreViewsState);
let newCoreViews = [...coreViews];
createdViewGroups.forEach((createdViewGroup) => {
cache.modify<CoreViewWithRelations>({
id: cache.identify({
__typename: 'CoreView',
id: createdViewGroup.viewId,
}),
fields: {
viewGroups: (existingViewGroups, { toReference }) => [
...(existingViewGroups ?? []),
toReference(createdViewGroup),
],
},
});
const toBeModifiedCoreView = newCoreViews.find(
(coreView) => coreView.id === createdViewGroup.viewId,
);
if (isDefined(toBeModifiedCoreView)) {
newCoreViews = [
...newCoreViews.filter(
(coreView) => coreView.id !== createdViewGroup.viewId,
),
{
...toBeModifiedCoreView,
viewGroups: [
...toBeModifiedCoreView.viewGroups,
createdViewGroup,
],
},
];
}
});
updatedViewGroups.forEach((updatedViewGroup) => {
cache.modify<CoreViewWithRelations>({
id: cache.identify({
__typename: 'CoreView',
id: updatedViewGroup.viewId,
}),
fields: {
viewGroups: (existingViewGroups, { readField, toReference }) =>
existingViewGroups.map((viewGroup) => {
const viewGroupId = readField<string>('id', viewGroup);
if (viewGroupId === updatedViewGroup.id) {
return toReference(updatedViewGroup);
}
return viewGroup;
}),
},
});
const toBeModifiedCoreView = newCoreViews.find(
(coreView) => coreView.id === updatedViewGroup.viewId,
);
if (isDefined(toBeModifiedCoreView)) {
newCoreViews = [
...newCoreViews.filter(
(coreView) => coreView.id !== updatedViewGroup.viewId,
),
{
...toBeModifiedCoreView,
viewGroups: [
...toBeModifiedCoreView.viewGroups.filter(
(viewGroup) => viewGroup.id !== updatedViewGroup.id,
),
updatedViewGroup,
],
},
];
}
});
deletedViewGroups.forEach((deletedViewGroup) => {
cache.modify<CoreViewWithRelations>({
id: cache.identify({
__typename: 'CoreView',
id: deletedViewGroup.viewId,
}),
fields: {
viewGroups: (existingViewGroups, { readField }) =>
existingViewGroups.filter(
(viewGroup) =>
readField('id', viewGroup) !== deletedViewGroup.id,
),
},
});
const toBeModifiedCoreView = newCoreViews.find(
(coreView) => coreView.id === deletedViewGroup.viewId,
);
if (isDefined(toBeModifiedCoreView)) {
newCoreViews = [
...newCoreViews.filter(
(coreView) => coreView.id !== deletedViewGroup.viewId,
),
{
...toBeModifiedCoreView,
viewGroups: toBeModifiedCoreView.viewGroups.filter(
(viewGroup) => viewGroup.id !== deletedViewGroup.id,
),
},
];
}
});
if (!isDeeplyEqual(coreViews, newCoreViews)) {
set(coreViewsState, newCoreViews);
}
},
[cache],
);
return {
triggerViewGroupOptimisticEffect,
};
};
@@ -3,9 +3,9 @@ import { ViewKey } from '@/views/types/ViewKey';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { selectorFamily } from 'recoil';
export const prefetchIndexViewIdFromObjectMetadataItemFamilySelector =
export const coreIndexViewIdFromObjectMetadataItemFamilySelector =
selectorFamily<string | undefined, { objectMetadataItemId: string }>({
key: 'prefetchIndexViewIdFromObjectMetadataItemFamilySelector',
key: 'coreIndexViewIdFromObjectMetadataItemFamilySelector',
get:
({ objectMetadataItemId }) =>
({ get }) => {
@@ -3,11 +3,11 @@ import { type View } from '@/views/types/View';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { selectorFamily } from 'recoil';
export const prefetchViewFromViewIdFamilySelector = selectorFamily<
export const coreViewFromViewIdFamilySelector = selectorFamily<
View | undefined,
{ viewId: string }
>({
key: 'prefetchViewFromViewIdFamilySelector',
key: 'coreViewFromViewIdFamilySelector',
get:
({ viewId }) =>
({ get }) => {
@@ -0,0 +1,21 @@
import { coreViewsState } from '@/views/states/coreViewState';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { selectorFamily } from 'recoil';
export const coreViewIdsFromObjectMetadataItemFamilySelector = selectorFamily<
string[],
{ objectMetadataItemId: string }
>({
key: 'coreViewIdsFromObjectMetadataItemFamilySelector',
get:
({ objectMetadataItemId }) =>
({ get }) => {
const coreViews = get(coreViewsState);
const views = coreViews.map(convertCoreViewToView);
return views
.filter((view) => view.objectMetadataId === objectMetadataItemId)
.map((view) => view.id);
},
});
@@ -1,8 +1,8 @@
import { coreViewsState } from '@/views/states/coreViewState';
import { selector } from 'recoil';
export const prefetchViewLengthSelector = selector<number>({
key: 'prefetchViewLengthSelector',
export const coreViewLengthFamilySelector = selector<number>({
key: 'coreViewLengthFamilySelector',
get: ({ get }) => {
const coreViews = get(coreViewsState);
@@ -3,11 +3,11 @@ import { type View } from '@/views/types/View';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { selectorFamily } from 'recoil';
export const prefetchViewsFromObjectMetadataItemFamilySelector = selectorFamily<
export const coreViewsFromObjectMetadataItemFamilySelector = selectorFamily<
View[],
{ objectMetadataItemId: string }
>({
key: 'prefetchViewsFromObjectMetadataItemFamilySelector',
key: 'coreViewsFromObjectMetadataItemFamilySelector',
get:
({ objectMetadataItemId }) =>
({ get }) => {
@@ -4,6 +4,7 @@ import { type RecordFilter } from '@/object-record/record-filter/types/RecordFil
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { isEmptinessOperand } from '@/object-record/record-filter/utils/isEmptinessOperand';
import { isRecordFilterConsideredEmpty } from '@/object-record/record-filter/utils/isRecordFilterConsideredEmpty';
import { parseJson } from 'twenty-shared/utils';
export const getRecordFilterLabelValue = ({
recordFilter,
@@ -30,7 +31,7 @@ export const getRecordFilterLabelValue = ({
}
}
if (recordFilter.type === 'SELECT' || recordFilter.type === 'MULTI_SELECT') {
const valueArray = JSON.parse(recordFilter.value);
const valueArray = parseJson<string[]>(recordFilter.value);
if (!Array.isArray(valueArray)) {
return '';
@@ -1,10 +1,10 @@
import { useEffect } from 'react';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { prefetchViewsFromObjectMetadataItemFamilySelector } from '@/prefetch/states/selector/prefetchViewsFromObjectMetadataItemFamilySelector';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { coreViewsFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/coreViewsFromObjectMetadataItemFamilySelector';
import { viewTypeIconMapping } from '@/views/types/ViewType';
import { useGetAvailableFieldsForKanban } from '@/views/view-picker/hooks/useGetAvailableFieldsForKanban';
import { useViewPickerMode } from '@/views/view-picker/hooks/useViewPickerMode';
@@ -48,7 +48,7 @@ export const ViewPickerContentEffect = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const viewsOnCurrentObject = useRecoilValue(
prefetchViewsFromObjectMetadataItemFamilySelector({
coreViewsFromObjectMetadataItemFamilySelector({
objectMetadataItemId: objectMetadataItem.id,
}),
);
@@ -1,8 +1,8 @@
import { FavoriteFolderPicker } from '@/favorites/favorite-folder-picker/components/FavoriteFolderPicker';
import { FavoriteFolderPickerEffect } from '@/favorites/favorite-folder-picker/components/FavoriteFolderPickerEffect';
import { FavoriteFolderPickerInstanceContext } from '@/favorites/favorite-folder-picker/states/context/FavoriteFolderPickerInstanceContext';
import { prefetchViewFromViewIdFamilySelector } from '@/prefetch/states/selector/prefetchViewFromViewIdFamilySelector';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { VIEW_PICKER_DROPDOWN_ID } from '@/views/view-picker/constants/ViewPickerDropdownId';
import { viewPickerReferenceViewIdComponentState } from '@/views/view-picker/states/viewPickerReferenceViewIdComponentState';
import { useRecoilValue } from 'recoil';
@@ -13,7 +13,7 @@ export const ViewPickerFavoriteFoldersDropdown = () => {
);
const view = useRecoilValue(
prefetchViewFromViewIdFamilySelector({
coreViewFromViewIdFamilySelector({
viewId: viewPickerReferenceViewId ?? '',
}),
);
@@ -3,7 +3,6 @@ import { type DropResult } from '@hello-pangea/dnd';
import { type MouseEvent, useCallback } from 'react';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { prefetchViewsFromObjectMetadataItemFamilySelector } from '@/prefetch/states/selector/prefetchViewsFromObjectMetadataItemFamilySelector';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
@@ -15,6 +14,7 @@ import { useChangeView } from '@/views/hooks/useChangeView';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { useOpenCreateViewDropdown } from '@/views/hooks/useOpenCreateViewDropown';
import { useUpdateView } from '@/views/hooks/useUpdateView';
import { coreViewsFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/coreViewsFromObjectMetadataItemFamilySelector';
import { ViewPickerOptionDropdown } from '@/views/view-picker/components/ViewPickerOptionDropdown';
import { VIEW_PICKER_DROPDOWN_ID } from '@/views/view-picker/constants/ViewPickerDropdownId';
import { useViewPickerMode } from '@/views/view-picker/hooks/useViewPickerMode';
@@ -35,7 +35,7 @@ export const ViewPickerListContent = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const viewsOnCurrentObject = useRecoilValue(
prefetchViewsFromObjectMetadataItemFamilySelector({
coreViewsFromObjectMetadataItemFamilySelector({
objectMetadataItemId: objectMetadataItem.id,
}),
);
@@ -1,12 +1,12 @@
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { prefetchViewsFromObjectMetadataItemFamilySelector } from '@/prefetch/states/selector/prefetchViewsFromObjectMetadataItemFamilySelector';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { useChangeView } from '@/views/hooks/useChangeView';
import { useDeleteView } from '@/views/hooks/useDeleteView';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { coreViewsFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/coreViewsFromObjectMetadataItemFamilySelector';
import { useCloseAndResetViewPicker } from '@/views/view-picker/hooks/useCloseAndResetViewPicker';
import { viewPickerIsDirtyComponentState } from '@/views/view-picker/states/viewPickerIsDirtyComponentState';
import { viewPickerIsPersistingComponentState } from '@/views/view-picker/states/viewPickerIsPersistingComponentState';
@@ -34,7 +34,7 @@ export const useDeleteViewFromCurrentState = (viewBarInstanceId?: string) => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const viewsOnCurrentObject = useRecoilValue(
prefetchViewsFromObjectMetadataItemFamilySelector({
coreViewsFromObjectMetadataItemFamilySelector({
objectMetadataItemId: objectMetadataItem.id,
}),
);
@@ -14,11 +14,9 @@ import { workflowRunDiagramAutomaticallyOpenedStepsComponentState } from '@/work
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { generateWorkflowRunDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowRunDiagram';
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { FeatureFlagKey } from '~/generated/graphql';
export const useRunWorkflowRunOpeningInCommandMenuSideEffects = () => {
const apolloCoreClient = useApolloCoreClient();
@@ -27,10 +25,6 @@ export const useRunWorkflowRunOpeningInCommandMenuSideEffects = () => {
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const isWorkflowBranchEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED,
);
const runWorkflowRunOpeningInCommandMenuSideEffects = useRecoilCallback(
({ snapshot, set }) =>
({
@@ -62,7 +56,6 @@ export const useRunWorkflowRunOpeningInCommandMenuSideEffects = () => {
steps: workflowRunRecord.state.flow.steps,
stepInfos: workflowRunRecord.state.stepInfos,
trigger: workflowRunRecord.state.flow.trigger,
isWorkflowBranchEnabled,
});
if (!isDefined(stepToOpenByDefault)) {
@@ -132,7 +125,6 @@ export const useRunWorkflowRunOpeningInCommandMenuSideEffects = () => {
[
apolloCoreClient.cache,
objectPermissionsByObjectMetadataId,
isWorkflowBranchEnabled,
openWorkflowRunViewStepInCommandMenu,
getIcon,
],
@@ -5,7 +5,6 @@ import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { WorkflowDiagramCustomMarkers } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramCustomMarkers';
import { WorkflowDiagramRightClickCommandMenu } from '@/workflow/workflow-diagram/components/WorkflowDiagramRightClickCommandMenu';
import { useRightDrawerState } from '@/workflow/workflow-diagram/hooks/useRightDrawerState';
import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState';
@@ -19,9 +18,10 @@ import {
type WorkflowDiagramNode,
type WorkflowDiagramNodeType,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getOrganizedDiagram } from '@/workflow/workflow-diagram/utils/getOrganizedDiagram';
import { WorkflowDiagramConnection } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramConnection';
import { WorkflowDiagramCustomMarkers } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramCustomMarkers';
import { useEdgeState } from '@/workflow/workflow-diagram/workflow-edges/hooks/useEdgeState';
import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
@@ -53,9 +53,6 @@ import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { Tag, type TagColor } from 'twenty-ui/components';
import { THEME_COMMON } from 'twenty-ui/theme';
import { FeatureFlagKey } from '~/generated/graphql';
import { useEdgeState } from '@/workflow/workflow-diagram/workflow-edges/hooks/useEdgeState';
import { WorkflowDiagramConnection } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramConnection';
const StyledResetReactflowStyles = styled.div`
height: 100%;
@@ -181,23 +178,15 @@ export const WorkflowDiagramCanvasBase = ({
const { setEdgeHovered, clearEdgeHover } = useEdgeState();
const isWorkflowBranchEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED,
);
const [workflowDiagramFlowInitialized, setWorkflowDiagramFlowInitialized] =
useState<boolean>(false);
const { nodes, edges } = useMemo(() => {
if (isDefined(workflowDiagram)) {
if (isWorkflowBranchEnabled) {
return workflowDiagram;
}
return getOrganizedDiagram(workflowDiagram);
return workflowDiagram;
}
return { nodes: [], edges: [] };
}, [workflowDiagram, isWorkflowBranchEnabled]);
}, [workflowDiagram]);
const { rightDrawerState } = useRightDrawerState();
const { isInRightDrawer } = useContext(ActionMenuContext);
@@ -407,10 +396,6 @@ export const WorkflowDiagramCanvasBase = ({
WorkflowDiagramNode,
WorkflowDiagramEdge
> = async ({ nodes, edges }) => {
if (!isWorkflowBranchEnabled) {
return false;
}
if (nodes.length === 0 && edges.length > 0) {
return true;
}
@@ -421,7 +406,7 @@ export const WorkflowDiagramCanvasBase = ({
const onDelete: OnDelete<WorkflowDiagramNode, WorkflowDiagramEdge> = async ({
edges,
}) => {
if (!isWorkflowBranchEnabled || !isDefined(onDeleteEdge)) {
if (!isDefined(onDeleteEdge)) {
return;
}
@@ -479,23 +464,19 @@ export const WorkflowDiagramCanvasBase = ({
onEdgeMouseLeave={onEdgeMouseLeave}
onNodesChange={handleNodesChanges}
onEdgesChange={handleEdgesChange}
onConnect={isWorkflowBranchEnabled ? onConnect : undefined}
onNodeDragStop={isWorkflowBranchEnabled ? onNodeDragStop : undefined}
onConnect={onConnect}
onNodeDragStop={onNodeDragStop}
onBeforeDelete={onBeforeDelete}
onDelete={onDelete}
selectNodesOnDrag={false}
proOptions={{ hideAttribution: true }}
multiSelectionKeyCode={null}
nodesFocusable={false}
nodesDraggable={isWorkflowBranchEnabled ? nodesDraggable : false}
edgesFocusable={
isWorkflowBranchEnabled ? isDefined(onDeleteEdge) : false
}
nodesDraggable={nodesDraggable}
edgesFocusable={isDefined(onDeleteEdge)}
panOnDrag={workflowDiagramPanOnDrag}
onPaneContextMenu={
isWorkflowBranchEnabled ? onPaneContextMenu : undefined
}
nodesConnectable={isWorkflowBranchEnabled ? nodesConnectable : false}
onPaneContextMenu={onPaneContextMenu}
nodesConnectable={nodesConnectable}
paneClickDistance={10} // Fix small unwanted user dragging does not select node
preventScrolling={false}
connectionLineComponent={WorkflowDiagramConnection}
@@ -506,7 +487,7 @@ export const WorkflowDiagramCanvasBase = ({
{children}
</ReactFlow>
{isDefined(handlePaneContextMenu) && isWorkflowBranchEnabled && (
{isDefined(handlePaneContextMenu) && (
<WorkflowDiagramRightClickCommandMenu />
)}
@@ -1,26 +1,16 @@
import { useEdgeState } from '@/workflow/workflow-diagram/workflow-edges/hooks/useEdgeState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import {
type OnSelectionChangeParams,
useOnSelectionChange,
} from '@xyflow/react';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated/graphql';
export const WorkflowDiagramCanvasEditableEffect = () => {
const { setEdgeSelected, clearEdgeSelected } = useEdgeState();
const isWorkflowBranchEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED,
);
const handleSelectedEdges = useCallback(
({ edges }: OnSelectionChangeParams) => {
if (!isWorkflowBranchEnabled) {
return;
}
const selectedEdge = edges?.[0];
if (!isDefined(selectedEdge)) {
@@ -34,7 +24,7 @@ export const WorkflowDiagramCanvasEditableEffect = () => {
target: selectedEdge.target,
});
},
[isWorkflowBranchEnabled, setEdgeSelected, clearEdgeSelected],
[setEdgeSelected, clearEdgeSelected],
);
useOnSelectionChange({
@@ -12,11 +12,9 @@ import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/state
import { getWorkflowVersionDiagram } from '@/workflow/workflow-diagram/utils/getWorkflowVersionDiagram';
import { mergeWorkflowDiagrams } from '@/workflow/workflow-diagram/utils/mergeWorkflowDiagrams';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useEffect } from 'react';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated/graphql';
export const WorkflowDiagramEffect = () => {
const workflowVisualizerWorkflowId = useRecoilComponentValue(
@@ -40,10 +38,6 @@ export const WorkflowDiagramEffect = () => {
workflowLastCreatedStepIdComponentState,
);
const isWorkflowBranchEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED,
);
const computeAndMergeNewWorkflowDiagram = useRecoilCallback(
({ snapshot, set }) => {
return (currentVersion: WorkflowVersion) => {
@@ -54,7 +48,6 @@ export const WorkflowDiagramEffect = () => {
const nextWorkflowDiagram = getWorkflowVersionDiagram({
workflowVersion: currentVersion,
isWorkflowBranchEnabled,
isEditable: true,
});
@@ -88,11 +81,7 @@ export const WorkflowDiagramEffect = () => {
set(workflowDiagramState, mergedWorkflowDiagram);
};
},
[
workflowDiagramState,
isWorkflowBranchEnabled,
workflowLastCreatedStepIdState,
],
[workflowDiagramState, workflowLastCreatedStepIdState],
);
const currentVersion = workflowWithCurrentVersion?.currentVersion;
@@ -18,12 +18,10 @@ import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/
import { generateWorkflowRunDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowRunDiagram';
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
import { selectWorkflowDiagramNode } from '@/workflow/workflow-diagram/utils/selectWorkflowDiagramNode';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useContext, useEffect } from 'react';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { FeatureFlagKey } from '~/generated/graphql';
export const WorkflowRunVisualizerEffect = ({
workflowRunId,
@@ -71,10 +69,6 @@ export const WorkflowRunVisualizerEffect = ({
const { isInRightDrawer } = useContext(ActionMenuContext);
const isWorkflowBranchEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED,
);
useEffect(() => {
setWorkflowRunId(workflowRunId);
}, [setWorkflowRunId, workflowRunId]);
@@ -133,7 +127,6 @@ export const WorkflowRunVisualizerEffect = ({
trigger: workflowRunState.flow.trigger,
steps: workflowRunState.flow.steps,
stepInfos: workflowRunState.stepInfos,
isWorkflowBranchEnabled,
});
if (workflowDiagramStatus !== 'done') {
@@ -204,7 +197,6 @@ export const WorkflowRunVisualizerEffect = ({
[
flowState,
getIcon,
isWorkflowBranchEnabled,
openWorkflowRunViewStepInCommandMenu,
workflowDiagramState,
workflowDiagramStatusState,
@@ -6,10 +6,8 @@ import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/wo
import { workflowVisualizerWorkflowVersionIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowVersionIdComponentState';
import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState';
import { getWorkflowVersionDiagram } from '@/workflow/workflow-diagram/utils/getWorkflowVersionDiagram';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated/graphql';
export const WorkflowVersionVisualizerEffect = ({
workflowVersionId,
@@ -31,10 +29,6 @@ export const WorkflowVersionVisualizerEffect = ({
const { populateStepsOutputSchema } = useStepsOutputSchema();
const isWorkflowBranchEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED,
);
useEffect(() => {
if (!isDefined(workflowVersion)) {
setFlow(undefined);
@@ -66,12 +60,11 @@ export const WorkflowVersionVisualizerEffect = ({
const nextWorkflowDiagram = getWorkflowVersionDiagram({
workflowVersion,
isWorkflowBranchEnabled,
isEditable: false,
});
setWorkflowDiagram(nextWorkflowDiagram);
}, [isWorkflowBranchEnabled, setWorkflowDiagram, workflowVersion]);
}, [setWorkflowDiagram, workflowVersion]);
useEffect(() => {
if (!isDefined(workflowVersion)) {
@@ -104,8 +104,6 @@ describe('generateWorkflowRunDiagram', () => {
trigger,
steps,
stepInfos,
isWorkflowBranchEnabled: true,
});
expect(result).toMatchInlineSnapshot(`
@@ -337,8 +335,6 @@ describe('generateWorkflowRunDiagram', () => {
trigger,
steps,
stepInfos,
isWorkflowBranchEnabled: true,
});
expect(result).toMatchInlineSnapshot(`
@@ -570,8 +566,6 @@ describe('generateWorkflowRunDiagram', () => {
trigger,
steps,
stepInfos,
isWorkflowBranchEnabled: true,
});
expect(result).toMatchInlineSnapshot(`
@@ -822,8 +816,6 @@ describe('generateWorkflowRunDiagram', () => {
trigger,
steps,
stepInfos,
isWorkflowBranchEnabled: true,
});
expect(result).toMatchInlineSnapshot(`
@@ -1055,8 +1047,6 @@ describe('generateWorkflowRunDiagram', () => {
trigger,
steps,
stepInfos,
isWorkflowBranchEnabled: true,
});
expect(result).toMatchInlineSnapshot(`
@@ -46,7 +46,6 @@ describe('transformFilterNodesAsEdges', () => {
nodes: diagram.nodes,
edges: diagram.edges,
defaultFilterEdgeType: 'filter--editable',
isWorkflowBranchEnabled: true,
});
expect(result.nodes).toEqual(diagram.nodes);
@@ -113,7 +112,6 @@ describe('transformFilterNodesAsEdges', () => {
nodes: diagram.nodes,
edges: diagram.edges,
defaultFilterEdgeType: 'filter--editable',
isWorkflowBranchEnabled: true,
});
// Should only have nodes A and C
@@ -259,7 +257,6 @@ describe('transformFilterNodesAsEdges', () => {
nodes: diagram.nodes,
edges: diagram.edges,
defaultFilterEdgeType: 'filter--editable',
isWorkflowBranchEnabled: true,
});
// Should only have nodes A, C, and D
@@ -352,7 +349,6 @@ describe('transformFilterNodesAsEdges', () => {
nodes: diagram.nodes,
edges: diagram.edges,
defaultFilterEdgeType: 'filter--editable',
isWorkflowBranchEnabled: true,
});
// Should only have node A (filter node B is removed)
@@ -435,7 +431,6 @@ describe('transformFilterNodesAsEdges', () => {
nodes: diagram.nodes,
edges: diagram.edges,
defaultFilterEdgeType: 'filter--editable',
isWorkflowBranchEnabled: true,
});
// Should have trigger and C nodes
@@ -485,86 +480,4 @@ describe('transformFilterNodesAsEdges', () => {
},
]);
});
it('should set selectable and deletable to false when isWorkflowBranchEnabled is false', () => {
const diagram: WorkflowDiagram = {
nodes: [
{
id: 'A',
data: {
nodeType: 'action',
actionType: 'CODE',
name: 'Step A',
hasNextStepIds: true,
position: { x: 0, y: 0 },
stepId: 'A',
},
position: { x: 0, y: 0 },
},
{
id: 'B',
data: {
nodeType: 'action',
actionType: 'FILTER',
name: 'Filter B',
hasNextStepIds: true,
position: { x: 0, y: 150 },
stepId: 'B',
},
position: { x: 0, y: 150 },
},
{
id: 'C',
data: {
nodeType: 'action',
actionType: 'SEND_EMAIL',
name: 'Step C',
hasNextStepIds: false,
position: { x: 0, y: 300 },
stepId: 'C',
},
position: { x: 0, y: 300 },
},
],
edges: [
{
id: 'A-B',
source: 'A',
target: 'B',
data: { edgeType: 'default' },
},
{
id: 'B-C',
source: 'B',
target: 'C',
data: { edgeType: 'default' },
},
],
};
const result = transformFilterNodesAsEdges({
nodes: diagram.nodes,
edges: diagram.edges,
defaultFilterEdgeType: 'filter--editable',
isWorkflowBranchEnabled: false,
});
// Should have one edge with filter data
expect(result.edges).toHaveLength(1);
expect(result.edges[0]).toEqual({
id: 'A-C-filter-B',
type: 'filter--editable',
source: 'A',
target: 'C',
selectable: false,
deletable: false,
data: {
edgeType: 'filter',
stepId: 'B',
name: 'Filter B',
runStatus: undefined,
filterSettings: {},
},
});
});
});
@@ -4,7 +4,6 @@ import {
} from '@/workflow/types/Workflow';
import { FIRST_NODE_POSITION } from '@/workflow/workflow-diagram/constants/FirstNodePosition';
import { VERTICAL_DISTANCE_BETWEEN_TWO_NODES } from '@/workflow/workflow-diagram/constants/VerticalDistanceBetweenTwoNodes';
import { WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION } from '@/workflow/workflow-diagram/workflow-edges/constants/WorkflowVisualizerEdgeDefaultConfiguration';
import {
type WorkflowDiagram,
type WorkflowDiagramEdge,
@@ -13,9 +12,9 @@ import {
type WorkflowDiagramStepNodeData,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowDiagramTriggerNode } from '@/workflow/workflow-diagram/utils/getWorkflowDiagramTriggerNode';
import { WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION } from '@/workflow/workflow-diagram/workflow-edges/constants/WorkflowVisualizerEdgeDefaultConfiguration';
import { WORKFLOW_DIAGRAM_EMPTY_TRIGGER_NODE_DEFINITION } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEmptyTriggerNodeDefinition';
import { getRootStepIds } from '@/workflow/workflow-trigger/utils/getRootStepIds';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { v4 } from 'uuid';
@@ -24,12 +23,10 @@ export const generateWorkflowDiagram = ({
trigger,
steps,
defaultEdgeType,
isWorkflowBranchEnabled = false,
}: {
trigger: WorkflowTrigger | undefined;
steps: Array<WorkflowStep>;
defaultEdgeType: WorkflowDiagramEdgeType;
isWorkflowBranchEnabled?: boolean;
}): WorkflowDiagram => {
const nodes: Array<WorkflowDiagramNode> = [];
const edges: Array<WorkflowDiagramEdge> = [];
@@ -38,19 +35,6 @@ export const generateWorkflowDiagram = ({
nodes.push(getWorkflowDiagramTriggerNode({ trigger }));
} else {
nodes.push(WORKFLOW_DIAGRAM_EMPTY_TRIGGER_NODE_DEFINITION);
const triggerNextStepIds =
isDefined(steps) && !isWorkflowBranchEnabled ? getRootStepIds(steps) : [];
triggerNextStepIds.forEach((stepId) => {
edges.push({
...WORKFLOW_VISUALIZER_EDGE_DEFAULT_CONFIGURATION,
type: 'blank',
id: v4(),
source: 'trigger',
target: stepId,
});
});
}
let levelYPos = FIRST_NODE_POSITION.y;
@@ -19,12 +19,10 @@ export const generateWorkflowRunDiagram = ({
trigger,
steps,
stepInfos,
isWorkflowBranchEnabled,
}: {
trigger: WorkflowTrigger;
steps: Array<WorkflowStep>;
stepInfos: WorkflowRunStepInfos | undefined;
isWorkflowBranchEnabled: boolean;
}): {
diagram: WorkflowRunDiagram;
stepToOpenByDefault:
@@ -107,7 +105,6 @@ export const generateWorkflowRunDiagram = ({
nodes: workflowRunDiagramNodes,
edges: workflowRunDiagramEdges,
defaultFilterEdgeType: 'filter--run',
isWorkflowBranchEnabled,
}),
stepToOpenByDefault,
};
@@ -22,11 +22,9 @@ const getEdgeTypeToCreateByDefault = ({
export const getWorkflowVersionDiagram = ({
workflowVersion,
isWorkflowBranchEnabled,
isEditable,
}: {
workflowVersion: WorkflowVersion | undefined;
isWorkflowBranchEnabled?: boolean;
isEditable: boolean;
}): WorkflowDiagram => {
if (!isDefined(workflowVersion)) {
@@ -39,13 +37,11 @@ export const getWorkflowVersionDiagram = ({
defaultEdgeType: getEdgeTypeToCreateByDefault({
isEditable,
}),
isWorkflowBranchEnabled,
});
return transformFilterNodesAsEdges({
nodes: diagram.nodes,
edges: diagram.edges,
defaultFilterEdgeType: isEditable ? 'filter--editable' : 'filter--readonly',
isWorkflowBranchEnabled: isWorkflowBranchEnabled === true,
});
};
@@ -12,12 +12,10 @@ export const transformFilterNodesAsEdges = <
nodes,
edges,
defaultFilterEdgeType,
isWorkflowBranchEnabled,
}: {
nodes: T[];
edges: U[];
defaultFilterEdgeType: WorkflowDiagramEdgeType;
isWorkflowBranchEnabled: boolean;
}): { nodes: T[]; edges: U[] } => {
const filterNodes = nodes.filter(
(node) =>
@@ -55,8 +53,8 @@ export const transformFilterNodesAsEdges = <
type: defaultFilterEdgeType,
id: `${incomingEdge.source}-${outgoingEdge.target}-filter-${filterNode.id}`,
target: outgoingEdge.target,
selectable: isWorkflowBranchEnabled === true,
deletable: isWorkflowBranchEnabled === true,
selectable: true,
deletable: true,
data: {
...incomingEdge.data,
edgeType: 'filter',
@@ -3,17 +3,17 @@ import { commandMenuNavigationStackState } from '@/command-menu/states/commandMe
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { useOpenWorkflowEditFilterInCommandMenu } from '@/workflow/workflow-diagram/hooks/useOpenWorkflowEditFilterInCommandMenu';
import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation';
import { type WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { WorkflowDiagramBaseEdge } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramBaseEdge';
import { WorkflowDiagramEdgeButtonGroup } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeButtonGroup';
import { WorkflowDiagramEdgeV2Container } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeV2Container';
import { WorkflowDiagramEdgeV2VisibilityContainer } from '@/workflow/workflow-diagram/workflow-edges/components/WorkflowDiagramEdgeV2VisibilityContainer';
import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/workflow-edges/constants/WorkflowDiagramEdgeOptionsClickOutsideId';
import { useOpenWorkflowEditFilterInCommandMenu } from '@/workflow/workflow-diagram/hooks/useOpenWorkflowEditFilterInCommandMenu';
import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation';
import { type WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { useEdgeState } from '@/workflow/workflow-diagram/workflow-edges/hooks/useEdgeState';
import { useCreateStep } from '@/workflow/workflow-steps/hooks/useCreateStep';
import { useDeleteEdge } from '@/workflow/workflow-steps/hooks/useDeleteEdge';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import {
EdgeLabelRenderer,
type EdgeProps,
@@ -23,8 +23,6 @@ import { type MouseEvent, useContext } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { IconFilter, IconPlus, IconTrash } from 'twenty-ui/display';
import { FeatureFlagKey } from '~/generated/graphql';
import { useEdgeState } from '@/workflow/workflow-diagram/workflow-edges/hooks/useEdgeState';
type WorkflowDiagramDefaultEdgeEditableProps = EdgeProps<WorkflowDiagramEdge>;
@@ -38,10 +36,6 @@ export const WorkflowDiagramDefaultEdgeEditable = ({
markerStart,
markerEnd,
}: WorkflowDiagramDefaultEdgeEditableProps) => {
const isWorkflowBranchEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED,
);
const { isInRightDrawer } = useContext(ActionMenuContext);
const { isEdgeHovered } = useEdgeState();
@@ -142,14 +136,10 @@ export const WorkflowDiagramDefaultEdgeEditable = ({
Icon: IconPlus,
onClick: handleNodeButtonClick,
},
...(isWorkflowBranchEnabled
? [
{
Icon: IconTrash,
onClick: handleDeleteBranch,
},
]
: []),
{
Icon: IconTrash,
onClick: handleDeleteBranch,
},
]}
selected={nodeCreationStarted}
/>
@@ -28,7 +28,6 @@ import { useDeleteEdge } from '@/workflow/workflow-steps/hooks/useDeleteEdge';
import { useDeleteStep } from '@/workflow/workflow-steps/hooks/useDeleteStep';
import { WorkflowStepFilterCounter } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterCounter';
import { useFilterCounter } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useFilterCounter';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
@@ -50,7 +49,6 @@ import {
} from 'twenty-ui/display';
import { IconButtonGroup } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
import { FeatureFlagKey } from '~/generated/graphql';
type WorkflowDiagramFilterEdgeEditableProps = EdgeProps<WorkflowDiagramEdge>;
@@ -98,9 +96,6 @@ export const WorkflowDiagramFilterEdgeEditable = ({
const { t } = useLingui();
const theme = useTheme();
const isWorkflowBranchEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED,
);
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
@@ -274,13 +269,11 @@ export const WorkflowDiagramFilterEdgeEditable = ({
LeftIcon={IconPlus}
onClick={handleAddNodeButtonClick}
/>
{isWorkflowBranchEnabled && (
<MenuItem
text={t`Delete branch`}
LeftIcon={IconTrash}
onClick={handleDeleteBranchClick}
/>
)}
<MenuItem
text={t`Delete branch`}
LeftIcon={IconTrash}
onClick={handleDeleteBranchClick}
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
@@ -2,11 +2,9 @@ import type { WorkflowRunStepStatus } from '@/workflow/types/Workflow';
import { NODE_HANDLE_HEIGHT_PX } from '@/workflow/workflow-diagram/constants/NodeHandleHeightPx';
import { NODE_HANDLE_WIDTH_PX } from '@/workflow/workflow-diagram/constants/NodeHandleWidthPx';
import { getWorkflowDiagramColors } from '@/workflow/workflow-diagram/utils/getWorkflowDiagramColors';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { Handle, Position, type HandleProps } from '@xyflow/react';
import { FeatureFlagKey } from '~/generated/graphql';
type WorkflowDiagramHandleSourceProps = {
selected: boolean;
@@ -89,15 +87,11 @@ export const WorkflowDiagramHandleSource = ({
readOnly = false,
runStatus,
}: WorkflowDiagramHandleSourceProps) => {
const isWorkflowBranchEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED,
);
return (
<StyledHandle
type="source"
position={Position.Bottom}
disableHoverEffect={!isWorkflowBranchEnabled || readOnly}
disableHoverEffect={readOnly}
selected={selected}
hovered={hovered}
runStatus={runStatus}
@@ -28,7 +28,6 @@ describe('getTriggerDefaultDefinition', () => {
eventName: `${generatedMockObjectMetadataItems[0].nameSingular}.created`,
outputSchema: {},
},
nextStepIds: [],
position: {
x: 0,
y: 0,
@@ -50,7 +49,6 @@ describe('getTriggerDefaultDefinition', () => {
eventName: `${generatedMockObjectMetadataItems[0].nameSingular}.updated`,
outputSchema: {},
},
nextStepIds: [],
position: {
x: 0,
y: 0,
@@ -72,7 +70,6 @@ describe('getTriggerDefaultDefinition', () => {
eventName: `${generatedMockObjectMetadataItems[0].nameSingular}.deleted`,
outputSchema: {},
},
nextStepIds: [],
position: {
x: 0,
y: 0,
@@ -94,7 +91,6 @@ describe('getTriggerDefaultDefinition', () => {
eventName: `${generatedMockObjectMetadataItems[0].nameSingular}.created`,
outputSchema: {},
},
nextStepIds: [],
position: {
x: 0,
y: 0,
@@ -118,7 +114,6 @@ describe('getTriggerDefaultDefinition', () => {
icon: COMMAND_MENU_DEFAULT_ICON,
isPinned: false,
},
nextStepIds: [],
position: {
x: 0,
y: 0,
@@ -141,7 +136,6 @@ describe('getTriggerDefaultDefinition', () => {
schedule: { day: 1, hour: 0, minute: 0 },
outputSchema: {},
},
nextStepIds: [],
position: {
x: 0,
y: 0,
@@ -164,7 +158,6 @@ describe('getTriggerDefaultDefinition', () => {
httpMethod: 'GET',
authentication: null,
},
nextStepIds: [],
position: {
x: 0,
y: 0,
@@ -1,25 +1,21 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import {
type WorkflowAction,
type WorkflowTrigger,
type WorkflowTriggerType,
} from '@/workflow/types/Workflow';
import { DATABASE_TRIGGER_TYPES } from '@/workflow/workflow-trigger/constants/DatabaseTriggerTypes';
import { getManualTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getManualTriggerDefaultSettings';
import { getRootStepIds } from '@/workflow/workflow-trigger/utils/getRootStepIds';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { assertUnreachable } from 'twenty-shared/utils';
// TODO: This needs to be migrated to the server
export const getTriggerDefaultDefinition = ({
defaultLabel,
type,
activeNonSystemObjectMetadataItems,
steps,
}: {
defaultLabel: string;
type: WorkflowTriggerType;
activeNonSystemObjectMetadataItems: ObjectMetadataItem[];
steps?: WorkflowAction[] | null;
}): WorkflowTrigger => {
if (activeNonSystemObjectMetadataItems.length === 0) {
throw new Error(
@@ -27,12 +23,9 @@ export const getTriggerDefaultDefinition = ({
);
}
const nextStepIds = isDefined(steps) ? getRootStepIds(steps) : [];
const baseTriggerDefinition = {
name: defaultLabel,
position: { x: 0, y: 0 },
nextStepIds,
};
switch (type) {
@@ -8,6 +8,7 @@ import { WorkspaceCleanerModule } from 'src/engine/workspace-manager/workspace-c
import { WorkspaceHealthCommandModule } from 'src/engine/workspace-manager/workspace-health/commands/workspace-health-command.module';
import { WorkspaceMigrationRunnerCommandsModule } from 'src/engine/workspace-manager/workspace-migration-runner/commands/workspace-migration-runner-commands.module';
import { WorkspaceSyncMetadataCommandsModule } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/workspace-sync-metadata-commands.module';
import { MessagingMessageCleanerModule } from 'src/modules/messaging/message-cleaner/messaging-message-cleaner.module';
@Module({
imports: [
@@ -16,6 +17,7 @@ import { WorkspaceSyncMetadataCommandsModule } from 'src/engine/workspace-manage
DatabaseCommandModule,
WorkspaceCleanerModule,
WorkspaceHealthCommandModule,
MessagingMessageCleanerModule,
WorkspaceMigrationRunnerCommandsModule,
ObjectMetadataModule,
FieldMetadataModule,
@@ -562,6 +562,7 @@ export class MigrateViewsToCoreCommand extends ActiveOrSuspendedWorkspacesMigrat
createdAt: new Date(filter.createdAt),
updatedAt: new Date(filter.updatedAt),
deletedAt: filter.deletedAt ? new Date(filter.deletedAt) : null,
subFieldName: filter.subFieldName,
};
const repository = queryRunner.manager.getRepository(ViewFilterEntity);
@@ -19,4 +19,6 @@ export enum GraphqlQueryRunnerExceptionCode {
RELATION_TARGET_OBJECT_METADATA_NOT_FOUND = 'RELATION_TARGET_OBJECT_METADATA_NOT_FOUND',
NOT_IMPLEMENTED = 'NOT_IMPLEMENTED',
INVALID_POST_HOOK_PAYLOAD = 'INVALID_POST_HOOK_PAYLOAD',
UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT = 'UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT',
UPSERT_MAX_RECORDS_EXCEEDED = 'UPSERT_MAX_RECORDS_EXCEEDED',
}
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { t } from '@lingui/core/macro';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
import { capitalize, isDefined } from 'twenty-shared/utils';
import {
@@ -41,6 +42,16 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
async resolve(
executionArgs: GraphqlQueryResolverExecutionArgs<CreateManyResolverArgs>,
): Promise<ObjectRecord[]> {
if (executionArgs.args.data.length > QUERY_MAX_RECORDS) {
throw new GraphqlQueryRunnerException(
`Maximum number of records to upsert is ${QUERY_MAX_RECORDS}.`,
GraphqlQueryRunnerExceptionCode.UPSERT_MAX_RECORDS_EXCEEDED,
{
userFriendlyMessage: t`Maximum number of records to upsert is ${QUERY_MAX_RECORDS}.`,
},
);
}
const { objectMetadataItemWithFieldMaps, objectMetadataMaps } =
executionArgs.options;
@@ -284,12 +295,36 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
const recordsToInsert: Partial<ObjectRecord>[] = [];
for (const record of records) {
let existingRecord: PartialObjectRecordWithId | null = null;
const matchingRecordId = this.getMatchingRecordId(
record,
conflictingFields,
existingRecords,
);
for (const field of conflictingFields) {
if (isDefined(matchingRecordId)) {
recordsToUpdate.push({ ...record, id: matchingRecordId });
} else {
recordsToInsert.push(record);
}
}
return { recordsToUpdate, recordsToInsert };
}
private getMatchingRecordId(
record: Partial<ObjectRecord>,
conflictingFields: {
baseField: string;
fullPath: string;
column: string;
}[],
existingRecords: PartialObjectRecordWithId[],
): string | undefined {
const matchingRecordIds = conflictingFields.reduce<string[]>(
(acc, field) => {
const requestFieldValue = this.getValueFromPath(record, field.fullPath);
const existingRec = existingRecords.find((existingRecord) => {
const matchingRecord = existingRecords.find((existingRecord) => {
const existingFieldValue = this.getValueFromPath(
existingRecord,
field.fullPath,
@@ -301,20 +336,35 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
);
});
if (existingRec) {
existingRecord = { ...record, id: existingRec.id };
break;
if (isDefined(matchingRecord)) {
acc.push(matchingRecord.id);
}
}
if (existingRecord) {
recordsToUpdate.push({ ...record, id: existingRecord.id });
} else {
recordsToInsert.push(record);
}
return acc;
},
[],
);
if ([...new Set(matchingRecordIds)].length > 1) {
const conflictingFieldsValues = conflictingFields
.map((field) => {
const value = this.getValueFromPath(record, field.fullPath);
return isDefined(value) ? `${field.fullPath}: ${value}` : undefined;
})
.filter(isDefined)
.join(', ');
throw new GraphqlQueryRunnerException(
`Multiple records found with the same unique field values for ${conflictingFieldsValues}. Cannot determine which record to update.`,
GraphqlQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT,
{
userFriendlyMessage: t`Multiple records found with the same unique field values for ${conflictingFieldsValues}. Cannot determine which record to update.`,
},
);
}
return { recordsToUpdate, recordsToInsert };
return matchingRecordIds[0];
}
private async processRecordsToUpdate({
@@ -400,6 +450,7 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
.where({
id: In(objectRecords.generatedMaps.map((record) => record.id)),
})
.withDeleted()
.take(QUERY_MAX_RECORDS)
.getMany();
@@ -24,6 +24,8 @@ export const graphqlQueryRunnerExceptionHandler = (
case GraphqlQueryRunnerExceptionCode.FIELD_NOT_FOUND:
case GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT:
case GraphqlQueryRunnerExceptionCode.NOT_IMPLEMENTED:
case GraphqlQueryRunnerExceptionCode.UPSERT_MULTIPLE_MATCHING_RECORDS_CONFLICT:
case GraphqlQueryRunnerExceptionCode.UPSERT_MAX_RECORDS_EXCEEDED:
throw new UserInputError(error);
case GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND:
throw new NotFoundError(error);
@@ -22,14 +22,6 @@ export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [
'https://twenty.com/images/lab/is-imap-smtp-caldav-enabled.png',
},
},
{
key: FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED,
metadata: {
label: 'Workflow Branches',
description: 'Create multiple branches on your workflows',
imagePath: 'https://twenty.com/images/lab/is-workflow-branch-enabled.png',
},
},
{
key: FeatureFlagKey.IS_MESSAGE_FOLDER_CONTROL_ENABLED,
metadata: {
@@ -7,7 +7,6 @@ export enum FeatureFlagKey {
IS_AI_ENABLED = 'IS_AI_ENABLED',
IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED',
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
IS_WORKFLOW_BRANCH_ENABLED = 'IS_WORKFLOW_BRANCH_ENABLED',
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
IS_CORE_VIEW_SYNCING_ENABLED = 'IS_CORE_VIEW_SYNCING_ENABLED',
IS_CORE_VIEW_ENABLED = 'IS_CORE_VIEW_ENABLED',
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { In, Not } from 'typeorm';
import { In } from 'typeorm';
import { type TimelineThread } from 'src/engine/core-modules/messaging/dtos/timeline-thread.dto';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
@@ -94,6 +94,7 @@ export class TimelineMessagingService {
await this.twentyORMManager.getRepository<MessageParticipantWorkspaceEntity>(
'messageParticipant',
);
const threadParticipants = await messageParticipantRepository
.createQueryBuilder()
.select('messageParticipant')
@@ -183,32 +184,11 @@ export class TimelineMessagingService {
'messageThread',
);
const threadsWithoutWorkspaceMember = await messageThreadRepository.find({
select: {
id: true,
},
where: {
id: In(messageThreadIds),
messages: {
messageChannelMessageAssociations: {
messageChannel: {
connectedAccount: {
accountOwnerId: Not(workspaceMemberId),
},
},
},
},
},
});
const threadIdsWithoutWorkspaceMember = threadsWithoutWorkspaceMember.map(
(thread) => thread.id,
);
const threadVisibility = await messageThreadRepository
.createQueryBuilder()
.select('messageThread.id', 'id')
.addSelect('messageChannel.visibility', 'visibility')
.addSelect('connectedAccount.accountOwnerId', 'accountOwnerId')
.leftJoin('messageThread.messages', 'message')
.leftJoin(
'message.messageChannelMessageAssociations',
@@ -218,46 +198,34 @@ export class TimelineMessagingService {
'messageChannelMessageAssociation.messageChannel',
'messageChannel',
)
.leftJoin('messageChannel.connectedAccount', 'connectedAccount')
.where('messageThread.id = ANY(:messageThreadIds)', {
messageThreadIds: threadIdsWithoutWorkspaceMember,
messageThreadIds: messageThreadIds,
})
.getRawMany();
const visibilityValues = Object.values(MessageChannelVisibility);
const threadVisibilityByThreadIdForWhichWorkspaceMemberIsNotOwner:
| {
[key: string]: MessageChannelVisibility;
}
| undefined = threadVisibility?.reduce(
(threadVisibilityAcc, threadVisibility) => {
threadVisibilityAcc[threadVisibility.id] =
visibilityValues[
Math.max(
visibilityValues.indexOf(threadVisibility.visibility),
visibilityValues.indexOf(
threadVisibilityAcc[threadVisibility.id] ??
MessageChannelVisibility.METADATA,
),
)
];
return threadVisibilityAcc;
},
{},
);
const threadVisibilityByThreadId: {
[key: string]: MessageChannelVisibility;
} = messageThreadIds.reduce((threadVisibilityAcc, messageThreadId) => {
// If the workspace member is not the owner of the thread, use the visibility value from the query
// @ts-expect-error legacy noImplicitAny
threadVisibilityAcc[messageThreadId] =
threadIdsWithoutWorkspaceMember.includes(messageThreadId)
? (threadVisibilityByThreadIdForWhichWorkspaceMemberIsNotOwner?.[
messageThreadId
] ?? MessageChannelVisibility.METADATA)
: MessageChannelVisibility.SHARE_EVERYTHING;
} = threadVisibility.reduce((threadVisibilityAcc, threadVisibility) => {
if (threadVisibility.accountOwnerId === workspaceMemberId) {
threadVisibilityAcc[threadVisibility.id] =
MessageChannelVisibility.SHARE_EVERYTHING;
return threadVisibilityAcc;
}
threadVisibilityAcc[threadVisibility.id] =
visibilityValues[
Math.max(
visibilityValues.indexOf(threadVisibility.visibility),
visibilityValues.indexOf(
threadVisibilityAcc[threadVisibility.id] ??
MessageChannelVisibility.METADATA,
),
)
];
return threadVisibilityAcc;
}, {});
@@ -130,7 +130,6 @@ describe('WorkspaceEntityManager', () => {
IS_AI_ENABLED: false,
IS_IMAP_SMTP_CALDAV_ENABLED: false,
IS_MORPH_RELATION_ENABLED: false,
IS_WORKFLOW_BRANCH_ENABLED: false,
IS_RELATION_CONNECT_ENABLED: false,
IS_CORE_VIEW_SYNCING_ENABLED: false,
IS_CORE_VIEW_ENABLED: false,
@@ -158,7 +157,6 @@ describe('WorkspaceEntityManager', () => {
IS_AI_ENABLED: false,
IS_IMAP_SMTP_CALDAV_ENABLED: false,
IS_MORPH_RELATION_ENABLED: false,
IS_WORKFLOW_BRANCH_ENABLED: false,
IS_RELATION_CONNECT_ENABLED: false,
IS_CORE_VIEW_SYNCING_ENABLED: false,
IS_CORE_VIEW_ENABLED: false,
@@ -40,11 +40,6 @@ export const seedFeatureFlags = async (
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED,
workspaceId: workspaceId,
value: false,
},
{
key: FeatureFlagKey.IS_IMAP_SMTP_CALDAV_ENABLED,
workspaceId: workspaceId,
@@ -140,6 +140,6 @@ export class BlocklistItemDeleteMessagesJob {
}
}
await this.threadCleanerService.cleanWorkspaceThreads(workspaceId);
await this.threadCleanerService.cleanOrphanMessagesAndThreads(workspaceId);
}
}
@@ -0,0 +1,39 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { Repository } from 'typeorm';
import {
ActiveOrSuspendedWorkspacesMigrationCommandRunner,
type RunOnWorkspaceArgs,
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { MessagingMessageCleanerService } from 'src/modules/messaging/message-cleaner/services/messaging-message-cleaner.service';
@Command({
name: 'messaging:message-cleaner-remove-orphans',
description: 'Remove orphan message and threads from messaging',
})
export class MessagingMessageCleanerRemoveOrphansCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(Workspace)
protected readonly workspaceRepository: Repository<Workspace>,
protected readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly messagingMessageCleanerService: MessagingMessageCleanerService,
) {
super(workspaceRepository, twentyORMGlobalManager);
}
override async runOnWorkspace({
workspaceId,
}: RunOnWorkspaceArgs): Promise<void> {
try {
await this.messagingMessageCleanerService.cleanOrphanMessagesAndThreads(
workspaceId,
);
} catch (error) {
this.logger.error('Error while deleting workflowRun', error);
}
}
}
@@ -23,6 +23,8 @@ export class MessagingConnectedAccountDeletionCleanupJob {
async handle(
data: MessagingConnectedAccountDeletionCleanupJobData,
): Promise<void> {
await this.messageCleanerService.cleanWorkspaceThreads(data.workspaceId);
await this.messageCleanerService.cleanOrphanMessagesAndThreads(
data.workspaceId,
);
}
}
@@ -1,15 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { MessagingMessageCleanerRemoveOrphansCommand } from 'src/modules/messaging/message-cleaner/commands/messaging-message-clearner-remove-orphans.command';
import { MessagingConnectedAccountDeletionCleanupJob } from 'src/modules/messaging/message-cleaner/jobs/messaging-connected-account-deletion-cleanup.job';
import { MessagingMessageCleanerConnectedAccountListener } from 'src/modules/messaging/message-cleaner/listeners/messaging-message-cleaner-connected-account.listener';
import { MessagingMessageCleanerService } from 'src/modules/messaging/message-cleaner/services/messaging-message-cleaner.service';
@Module({
imports: [],
imports: [TypeOrmModule.forFeature([Workspace])],
providers: [
MessagingMessageCleanerService,
MessagingConnectedAccountDeletionCleanupJob,
MessagingMessageCleanerConnectedAccountListener,
MessagingMessageCleanerRemoveOrphansCommand,
],
exports: [MessagingMessageCleanerService],
})
@@ -1,9 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import { IsNull } from 'typeorm';
import chunk from 'lodash.chunk';
import { In, IsNull } from 'typeorm';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { type MessageThreadWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-thread.workspace-entity';
import { type MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
import { deleteUsingPagination } from 'src/modules/messaging/message-cleaner/utils/delete-using-pagination.util';
@@ -11,20 +13,123 @@ import { deleteUsingPagination } from 'src/modules/messaging/message-cleaner/uti
@Injectable()
export class MessagingMessageCleanerService {
private readonly logger = new Logger(MessagingMessageCleanerService.name);
constructor(private readonly twentyORMManager: TwentyORMManager) {}
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
async deleteMessagesChannelMessageAssociationsAndRelatedOrphans({
workspaceId,
messageExternalIds,
messageChannelId,
}: {
workspaceId: string;
messageExternalIds: string[];
messageChannelId: string;
}) {
const messageRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<MessageWorkspaceEntity>(
workspaceId,
'message',
);
const messageChannelMessageAssociationRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<MessageChannelMessageAssociationWorkspaceEntity>(
workspaceId,
'messageChannelMessageAssociation',
);
public async cleanWorkspaceThreads(workspaceId: string) {
const messageThreadRepository =
await this.twentyORMManager.getRepository<MessageThreadWorkspaceEntity>(
await this.twentyORMGlobalManager.getRepositoryForWorkspace<MessageThreadWorkspaceEntity>(
workspaceId,
'messageThread',
);
const messageExternalIdsChunks = chunk(messageExternalIds, 500);
for (const messageExternalIdsChunk of messageExternalIdsChunks) {
const messageChannelMessageAssociationsToDelete =
await messageChannelMessageAssociationRepository.find({
where: {
messageExternalId: In(messageExternalIdsChunk),
messageChannelId,
},
});
if (messageChannelMessageAssociationsToDelete.length <= 0) {
continue;
}
await messageChannelMessageAssociationRepository.delete(
messageChannelMessageAssociationsToDelete.map(({ id }) => id),
);
this.logger.log(
`WorkspaceId: ${workspaceId} Deleting ${messageChannelMessageAssociationsToDelete.length} message channel message associations`,
);
const orphanMessages = await messageRepository.find({
where: {
id: In(
messageChannelMessageAssociationsToDelete.map(
({ messageId }) => messageId,
),
),
messageChannelMessageAssociations: {
id: IsNull(),
},
},
});
if (orphanMessages.length <= 0) {
continue;
}
this.logger.log(
`WorkspaceId: ${workspaceId} Deleting ${orphanMessages.length} orphan messages`,
);
await messageRepository.delete(orphanMessages.map(({ id }) => id));
const orphanMessageThreads = await messageThreadRepository.find({
where: {
id: In(orphanMessages.map(({ messageThreadId }) => messageThreadId)),
messages: {
id: IsNull(),
},
},
});
if (orphanMessageThreads.length <= 0) {
continue;
}
this.logger.log(
`WorkspaceId: ${workspaceId} Deleting ${orphanMessageThreads.length} orphan message threads`,
);
await messageThreadRepository.delete(
orphanMessageThreads.map(({ id }) => id),
);
}
}
public async cleanOrphanMessagesAndThreads(workspaceId: string) {
const messageThreadRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<MessageThreadWorkspaceEntity>(
workspaceId,
'messageThread',
);
const messageRepository =
await this.twentyORMManager.getRepository<MessageWorkspaceEntity>(
await this.twentyORMGlobalManager.getRepositoryForWorkspace<MessageWorkspaceEntity>(
workspaceId,
'message',
);
const workspaceDataSource = await this.twentyORMManager.getDatasource();
const workspaceDataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
workspaceId,
});
await workspaceDataSource.transaction(
async (transactionManager: WorkspaceEntityManager) => {
@@ -1,18 +1,14 @@
import { Injectable, Logger } from '@nestjs/common';
import { gmail_v1 } from 'googleapis';
import { isDefined } from 'twenty-shared/utils';
import {
MessageFolder,
MessageFolderDriver,
} from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MESSAGING_GMAIL_EXCLUDED_CATEGORIES } from 'src/modules/messaging/message-import-manager/drivers/gmail/constants/messaging-gmail-excluded-categories';
import { MESSAGING_GMAIL_DEFAULT_NOT_SYNCED_LABELS } from 'src/modules/messaging/message-import-manager/drivers/gmail/constants/messaging-gmail-default-not-synced-labels';
import { GmailClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/gmail-client.provider';
import { GmailHandleErrorService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-handle-error.service';
import { computeGmailCategoryLabelId } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-gmail-category-label-id.util';
@Injectable()
export class GmailGetAllFoldersService implements MessageFolderDriver {
@@ -23,24 +19,8 @@ export class GmailGetAllFoldersService implements MessageFolderDriver {
private readonly gmailHandleErrorService: GmailHandleErrorService,
) {}
private isExcludedCategoryFolder(labelId: string): boolean {
const excludedCategoryIds = MESSAGING_GMAIL_EXCLUDED_CATEGORIES.map(
(category) => computeGmailCategoryLabelId(category),
);
return excludedCategoryIds.includes(labelId);
}
private isIncludedFolder(label: gmail_v1.Schema$Label): boolean {
if (!isDefined(label.id)) {
return false;
}
const isTargetSystemFolder =
label.type === 'system' && (label.id === 'INBOX' || label.id === 'SENT');
const isUserFolder = label.type === 'user';
return isTargetSystemFolder || isUserFolder;
private isSyncedByDefault(labelId: string): boolean {
return !MESSAGING_GMAIL_DEFAULT_NOT_SYNCED_LABELS.includes(labelId);
}
async getAllMessageFolders(
@@ -74,21 +54,12 @@ export class GmailGetAllFoldersService implements MessageFolderDriver {
continue;
}
if (this.isExcludedCategoryFolder(label.id)) {
continue;
}
if (!this.isIncludedFolder(label)) {
continue;
}
const isSentFolder = label.id === 'SENT';
const isSyncedByDefault = label.id === 'INBOX' || label.id === 'SENT';
folders.push({
externalId: label.id,
name: label.name,
isSynced: isSyncedByDefault,
isSynced: this.isSyncedByDefault(label.id),
isSentFolder,
});
}
@@ -69,6 +69,7 @@ export class SyncMessageFoldersService {
messageFolderRepository,
});
// TODO: we should delete folders that are not in the list anymore
for (const folder of folders) {
const existingFolder = this.findExistingFolderInMap(
existingFolderMap,
@@ -80,8 +81,6 @@ export class SyncMessageFoldersService {
existingFolder.id,
{
name: folder.name,
isSynced: folder.isSynced,
isSentFolder: folder.isSentFolder,
externalId: folder.externalId,
},
manager,
@@ -0,0 +1,10 @@
export const MESSAGING_GMAIL_DEFAULT_NOT_SYNCED_LABELS = [
'CATEGORY_PROMOTIONS',
'CATEGORY_SOCIAL',
'CATEGORY_FORUMS',
'CATEGORY_UPDATES',
'TRASH',
'SPAM',
'DRAFT',
'CHAT',
];
@@ -1,8 +0,0 @@
import { GmailDefaultMessageCategory } from 'src/modules/messaging/message-import-manager/drivers/gmail/types/gmail-default-message-category.type';
export const MESSAGING_GMAIL_EXCLUDED_CATEGORIES = [
GmailDefaultMessageCategory.promotions,
GmailDefaultMessageCategory.forums,
GmailDefaultMessageCategory.social,
GmailDefaultMessageCategory.updates,
];
@@ -10,14 +10,11 @@ import {
MessageImportDriverException,
MessageImportDriverExceptionCode,
} from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-import-driver.exception';
import { MESSAGING_GMAIL_EXCLUDED_CATEGORIES } from 'src/modules/messaging/message-import-manager/drivers/gmail/constants/messaging-gmail-excluded-categories';
import { MESSAGING_GMAIL_USERS_MESSAGES_LIST_MAX_RESULT } from 'src/modules/messaging/message-import-manager/drivers/gmail/constants/messaging-gmail-users-messages-list-max-result.constant';
import { GmailClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/gmail-client.provider';
import { GmailGetHistoryService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-get-history.service';
import { GmailHandleErrorService } from 'src/modules/messaging/message-import-manager/drivers/gmail/services/gmail-handle-error.service';
import { computeGmailCategoryExcludeSearchFilter } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-gmail-category-excude-search-filter.util';
import { computeGmailCategoryLabelId } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-gmail-category-label-id.util';
import { mapGmailDefaultFolderToCategoryOrUndefined } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/map-gmail-default-folder-to-category';
import { computeGmailExcludeSearchFilter } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-gmail-exclude-search-filter.util';
import { type GetMessageListsArgs } from 'src/modules/messaging/message-import-manager/types/get-message-lists-args.type';
import { type GetMessageListsResponse } from 'src/modules/messaging/message-import-manager/types/get-message-lists-response.type';
import { assertNotNull } from 'src/utils/assert';
@@ -48,7 +45,9 @@ export class GmailGetMessageListService {
let hasMoreMessages = true;
const messageExternalIds: string[] = [];
const excludedCategories = this.computeExcludedCategories(messageFolders);
const excludedSearchFilter =
computeGmailExcludeSearchFilter(messageFolders);
while (hasMoreMessages) {
const messageList = await gmailClient.users.messages
@@ -56,8 +55,7 @@ export class GmailGetMessageListService {
userId: 'me',
maxResults: MESSAGING_GMAIL_USERS_MESSAGES_LIST_MAX_RESULT,
pageToken,
q: computeGmailCategoryExcludeSearchFilter(excludedCategories),
labelIds: this.getCustomLabelIds(messageFolders),
q: excludedSearchFilter,
})
.catch((error) => {
this.logger.error(
@@ -157,7 +155,7 @@ export class GmailGetMessageListService {
const { messagesAdded, messagesDeleted } =
await this.gmailGetHistoryService.getMessageIdsFromHistory(history);
const messageIdsToFilter = await this.getEmailIdsFromExcludedCategories(
const messageIdsToFilter = await this.getEmailIdsFromExcludedFolders(
gmailClient,
messageChannel.syncCursor,
messageFolders,
@@ -185,36 +183,30 @@ export class GmailGetMessageListService {
];
}
private computeExcludedCategories(
messageFolders: Pick<MessageFolderWorkspaceEntity, 'name' | 'externalId'>[],
) {
const includedDefaultCategories = messageFolders
.map((messageFolder) =>
mapGmailDefaultFolderToCategoryOrUndefined(messageFolder.name),
)
.filter(isDefined);
return MESSAGING_GMAIL_EXCLUDED_CATEGORIES.filter(
(excludedCategory) =>
!includedDefaultCategories.includes(excludedCategory),
);
}
private async getEmailIdsFromExcludedCategories(
private async getEmailIdsFromExcludedFolders(
gmailClient: gmailV1.Gmail,
lastSyncHistoryId: string,
messageFolders: Pick<MessageFolderWorkspaceEntity, 'name' | 'externalId'>[],
messageFolders: Pick<
MessageFolderWorkspaceEntity,
'name' | 'externalId' | 'isSynced'
>[],
): Promise<string[]> {
const emailIds: string[] = [];
const excludedCategories = this.computeExcludedCategories(messageFolders);
const toBeExcludedFolders = messageFolders.filter(
(folder) => !folder.isSynced,
);
for (const folder of toBeExcludedFolders) {
if (!isDefined(folder.externalId)) {
continue;
}
for (const category of excludedCategories) {
const { history } = await this.gmailGetHistoryService.getHistory(
gmailClient,
lastSyncHistoryId,
['messageAdded'],
computeGmailCategoryLabelId(category),
folder.externalId,
);
const emailIdsFromCategory = history
@@ -229,23 +221,4 @@ export class GmailGetMessageListService {
return emailIds;
}
private getCustomLabelIds(
messageFolders: Pick<
MessageFolderWorkspaceEntity,
'name' | 'externalId' | 'isSynced'
>[],
): string[] | undefined {
const customLabelIds = messageFolders
.filter(
(folder) =>
folder.externalId &&
folder.isSynced &&
!mapGmailDefaultFolderToCategoryOrUndefined(folder.name),
)
.map((folder) => folder.externalId)
.filter((id): id is string => !!id);
return customLabelIds.length > 0 ? customLabelIds : undefined;
}
}
@@ -1,6 +0,0 @@
export enum GmailDefaultMessageCategory {
promotions = 'promotions',
social = 'social',
forums = 'forums',
updates = 'updates',
}
@@ -1,27 +0,0 @@
import { computeGmailCategoryExcludeSearchFilter } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-gmail-category-excude-search-filter.util';
describe('computeGmailCategoryExcludeSearchFilter', () => {
it('should return correct exclude search filter with empty category array', () => {
const result = computeGmailCategoryExcludeSearchFilter([]);
expect(result).toBe('');
});
it('should return correct exclude search filter with one category', () => {
const result = computeGmailCategoryExcludeSearchFilter(['CATEGORY1']);
expect(result).toBe('-category:CATEGORY1');
});
it('should return correct exclude search filter with multiple categories', () => {
const result = computeGmailCategoryExcludeSearchFilter([
'CATEGORY1',
'CATEGORY2',
'CATEGORY3',
]);
expect(result).toBe(
'-category:CATEGORY1 -category:CATEGORY2 -category:CATEGORY3',
);
});
});
@@ -1,9 +0,0 @@
import { computeGmailCategoryLabelId } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-gmail-category-label-id.util';
describe('computeGmailCategoryLabelId', () => {
it('should return correct category label id', () => {
const result = computeGmailCategoryLabelId('CATEGORY1');
expect(result).toBe('CATEGORY_CATEGORY1');
});
});
@@ -0,0 +1,69 @@
import { computeGmailExcludeSearchFilter } from 'src/modules/messaging/message-import-manager/drivers/gmail/utils/compute-gmail-exclude-search-filter.util';
describe('computeGmailExcludeSearchFilter', () => {
it('should return correct exclude search filter with empty label array', () => {
const result = computeGmailExcludeSearchFilter([]);
expect(result).toBe('');
});
it('should return correct exclude search filter with one label', () => {
const result = computeGmailExcludeSearchFilter([
{
externalId: 'LABEL1',
isSynced: false,
},
]);
expect(result).toBe('-label:LABEL1');
});
it('should return correct exclude search filter with multiple categories', () => {
const result = computeGmailExcludeSearchFilter([
{
externalId: 'LABEL1',
isSynced: false,
},
{
externalId: 'LABEL2',
isSynced: false,
},
{
externalId: 'LABEL3',
isSynced: false,
},
]);
expect(result).toBe('-label:LABEL1 -label:LABEL2 -label:LABEL3');
});
it('should return correct exclude search filter with one label that is synced', () => {
const result = computeGmailExcludeSearchFilter([
{
externalId: 'LABEL1',
isSynced: true,
},
]);
expect(result).toBe('');
});
it('should return correct exclude search filter with multiple categories that are synced', () => {
const result = computeGmailExcludeSearchFilter([
{
externalId: 'LABEL1',
isSynced: true,
},
{
externalId: 'LABEL2',
isSynced: false,
},
{
externalId: 'LABEL3',
isSynced: true,
},
]);
expect(result).toBe('-label:LABEL2');
});
});
@@ -1,3 +0,0 @@
export const computeGmailCategoryExcludeSearchFilter = (
excludedCategories: string[],
) => excludedCategories.map((category) => `-category:${category}`).join(' ');
@@ -1,2 +0,0 @@
export const computeGmailCategoryLabelId = (category: string) =>
`CATEGORY_${category.toUpperCase()}`;
@@ -0,0 +1,12 @@
import { type MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
export const computeGmailExcludeSearchFilter = (
messageFolders: Pick<
MessageFolderWorkspaceEntity,
'externalId' | 'isSynced'
>[],
) =>
messageFolders
.filter((folder) => !folder.isSynced)
.map((folder) => `-label:${folder.externalId}`)
.join(' ');
@@ -1,37 +0,0 @@
import { isDefined } from 'twenty-shared/utils';
import { GmailDefaultMessageCategory } from 'src/modules/messaging/message-import-manager/drivers/gmail/types/gmail-default-message-category.type';
import { GmailDefaultMessageFolder } from 'src/modules/messaging/message-import-manager/drivers/gmail/types/gmail-default-message-folder.type';
const DEFAULT_FOLDER_NAME_TO_CATEGORY_MAPPING = [
{
folderName: GmailDefaultMessageFolder.CATEGORY_FORUMS,
category: GmailDefaultMessageCategory.forums,
},
{
folderName: GmailDefaultMessageFolder.CATEGORY_PROMOTIONS,
category: GmailDefaultMessageCategory.promotions,
},
{
folderName: GmailDefaultMessageFolder.CATEGORY_SOCIAL,
category: GmailDefaultMessageCategory.social,
},
{
folderName: GmailDefaultMessageFolder.CATEGORY_UPDATES,
category: GmailDefaultMessageCategory.updates,
},
];
export const mapGmailDefaultFolderToCategoryOrUndefined = (
messageFolderName: string,
) => {
const mapping = DEFAULT_FOLDER_NAME_TO_CATEGORY_MAPPING.find(
({ folderName }) => folderName === messageFolderName,
);
if (!isDefined(mapping)) {
return undefined;
}
return mapping.category;
};
@@ -33,12 +33,15 @@ export class ImapGetMessageListService {
messageFolders,
}: GetMessageListsArgs): Promise<GetMessageListsResponse> {
let client: ImapFlow | null = null;
const syncedMessageFolders = messageFolders.filter(
(folder) => folder.isSynced,
);
try {
client = await this.imapClientProvider.getClient(connectedAccount);
const result: GetMessageListsResponse = [];
for (const folder of messageFolders) {
for (const folder of syncedMessageFolders) {
this.logger.log(`Processing folder: ${folder.name}`);
try {
@@ -76,7 +79,7 @@ export class ImapGetMessageListService {
this.imapHandleErrorService.handleImapMessageListFetchError(error);
return messageFolders.map((folder) => ({
return syncedMessageFolders.map((folder) => ({
messageExternalIds: [],
nextSyncCursor: folder.syncCursor || '',
previousSyncCursor: folder.syncCursor,
@@ -47,7 +47,11 @@ export class MicrosoftGetMessageListService {
);
}
for (const folder of messageFolders) {
const syncedMessageFolders = messageFolders.filter(
(folder) => folder.isSynced,
);
for (const folder of syncedMessageFolders) {
const response = await this.getMessageList(connectedAccount, folder);
result.push({
@@ -91,17 +91,16 @@ export class MessagingMessageListFetchService {
'messageFolder',
);
const messageFoldersToSync = await messageFolderRepository.find({
const messageFolders = await messageFolderRepository.find({
where: {
messageChannelId: messageChannel.id,
isSynced: true,
},
});
const messageLists =
await this.messagingGetMessageListService.getMessageLists(
messageChannelWithFreshTokens,
messageFoldersToSync,
messageFolders,
);
await this.cacheStorage.del(
@@ -124,7 +123,11 @@ export class MessagingMessageListFetchService {
let totalMessagesToImportCount = 0;
this.logger.log(
`messageChannelId: ${messageChannel.id} Is full sync: ${isFullSync} and toImportCount: ${messageExternalIds.length}, toDeleteCount: ${messageExternalIdsToDelete.length}`,
`messageChannelId: ${messageChannel.id} Is full sync: ${isFullSync} and toImportCount: ${messageExternalIds.length}, toDeleteCount: ${messageExternalIdsToDelete.length}, cursors: ${messageLists.map(
(messageList) => {
messageList.nextSyncCursor;
},
)}`,
);
const messageChannelMessageAssociationRepository =
@@ -207,26 +210,22 @@ export class MessagingMessageListFetchService {
const toDeleteChunks = chunk(allMessageExternalIdsToDelete, 200);
for (const [index, toDeleteChunk] of toDeleteChunks.entries()) {
await messageChannelMessageAssociationRepository.delete({
messageChannelId: messageChannelWithFreshTokens.id,
messageExternalId: In(toDeleteChunk),
});
this.logger.log(
`messageChannelId: ${messageChannel.id} Deleted ${toDeleteChunk.length} message channel message associations in batch ${index + 1}`,
`messageChannelId: ${messageChannel.id} Deleting ${toDeleteChunk.length} message channel message associations in batch ${index + 1}`,
);
await this.messagingMessageCleanerService.deleteMessagesChannelMessageAssociationsAndRelatedOrphans(
{
workspaceId,
messageExternalIds: toDeleteChunk.filter((messageExternalId) =>
isNonEmptyString(messageExternalId),
),
messageChannelId: messageChannelWithFreshTokens.id,
},
);
}
}
this.logger.log(
`messageChannelId: ${messageChannel.id} launching workspace thread cleanup`,
);
// TODO: Re-enable this after we have a way to clean up threads without impacting the message import
// await this.messagingMessageCleanerService.cleanWorkspaceThreads(
// workspaceId,
// );
this.logger.log(
`messageChannelId: ${messageChannel.id} Total messages to import count: ${totalMessagesToImportCount}`,
);
@@ -2,7 +2,6 @@ import { Scope } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
@@ -18,7 +17,6 @@ import {
WorkflowRunException,
WorkflowRunExceptionCode,
} from 'src/modules/workflow/workflow-runner/exceptions/workflow-run.exception';
import { getRootSteps } from 'src/modules/workflow/workflow-runner/utils/get-root-steps.utils';
import { WorkflowRunQueueWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run-queue/workspace-services/workflow-run-queue.workspace-service';
import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service';
import { WorkflowTriggerType } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
@@ -113,17 +111,7 @@ export class RunWorkflowJob {
workspaceId,
});
const rootSteps = getRootSteps(workflowVersion.steps);
const isWorkflowBranchEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED,
workspaceId,
);
const stepIds = isWorkflowBranchEnabled
? (workflowVersion.trigger.nextStepIds ?? [])
: (rootSteps.map((step) => step.id) ?? []);
const stepIds = workflowVersion.trigger.nextStepIds ?? [];
await this.workflowExecutorWorkspaceService.executeFromSteps({
stepIds,
@@ -1,85 +0,0 @@
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { getRootSteps } from 'src/modules/workflow/workflow-runner/utils/get-root-steps.utils';
describe('getRootSteps', () => {
it('should return the root steps', () => {
const steps = [
{
id: 'step1',
nextStepIds: ['step2'],
},
{ id: 'step2', nextStepIds: undefined },
] as WorkflowAction[];
const expectedRootSteps = [
{
id: 'step1',
nextStepIds: ['step2'],
},
] as WorkflowAction[];
expect(getRootSteps(steps)).toEqual(expectedRootSteps);
});
it('should not consider step order', () => {
const steps = [
{ id: 'step2', nextStepIds: undefined },
{
id: 'step1',
nextStepIds: ['step2'],
},
] as WorkflowAction[];
const expectedRootSteps = [
{
id: 'step1',
nextStepIds: ['step2'],
},
] as WorkflowAction[];
expect(getRootSteps(steps)).toEqual(expectedRootSteps);
});
it('should handle multiple root steps', () => {
const steps = [
{
id: 'step1',
nextStepIds: ['step3'],
},
{
id: 'step2',
nextStepIds: ['step3'],
},
{ id: 'step3', nextStepIds: ['step4'] },
{ id: 'step4', nextStepIds: undefined },
] as WorkflowAction[];
const expectedRootSteps = [
{
id: 'step1',
nextStepIds: ['step3'],
},
{
id: 'step2',
nextStepIds: ['step3'],
},
] as WorkflowAction[];
expect(getRootSteps(steps)).toEqual(expectedRootSteps);
});
it('should throw if buggy steps provided', () => {
const steps = [
{
id: 'step1',
nextStepIds: ['step2'],
},
{
id: 'step2',
nextStepIds: ['step1'],
},
] as WorkflowAction[];
expect(() => getRootSteps(steps)).toThrow('No root step found');
});
});
@@ -1,24 +0,0 @@
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import {
WorkflowRunException,
WorkflowRunExceptionCode,
} from 'src/modules/workflow/workflow-runner/exceptions/workflow-run.exception';
export const getRootSteps = (steps: WorkflowAction[]): WorkflowAction[] => {
const childIds = new Set<string>();
for (const step of steps) {
step.nextStepIds?.forEach((id) => childIds.add(id));
}
const rootSteps = steps.filter((step) => !childIds.has(step.id));
if (rootSteps.length === 0) {
throw new WorkflowRunException(
'No root step found',
WorkflowRunExceptionCode.WORKFLOW_ROOT_STEP_NOT_FOUND,
);
}
return rootSteps;
};

Some files were not shown because too many files have changed in this diff Show More