Compare commits

..
1 Commits
Author SHA1 Message Date
MarieandGitHub 86df188e83 [fix] Fix switch view type from "Layout" switcher (#16382)
In this previous work aiming at eliminating the creation of viewGroups
being separated from the views, I removed the creation of viewGroups
from the FE at view creation, but forgot to do it at view update when
the user chooses "change Layout" option (from Table/Kanban/Calendar to
another).

In this PR
- I removed creation, deletion and destroy of viewGroups from
usePersistViewGroupRecords. I left update because it useful for
visibility and position
- since usePersistViewGroups record was also handling optimistic, I
moved optimistic logic to a new hook useViewsSideEffectsOnViewGroups. I
realized that so far we optimistic is only useful for creation. For the
update though we do need to compute the view groups that will be created
to be able to call loadRecordIndexState, so I created a function for
that, but I did not seek to perform real optimistic, with writing and
deleting groups from the cache as it was a bit heavier and not in use
for now and I want to merge this asap as it fixes the creation of
viewGroups.
2025-12-08 15:05:05 +01:00
13 changed files with 326 additions and 420 deletions
@@ -1,75 +1,35 @@
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { isSwitchingToKanbanViewTypeComponentState } from '@/object-record/record-board/states/isSwitchingToKanbanViewTypeComponentState';
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 { usePersistViewGroupRecords } from '@/views/hooks/internal/usePersistViewGroup';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
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 { useGetAvailableFieldsForCalendar } from '@/views/view-picker/hooks/useGetAvailableFieldsForCalendar';
import { useGetAvailableFieldsForKanban } from '@/views/view-picker/hooks/useGetAvailableFieldsForKanban';
import { useCallback } from 'react';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { ViewCalendarLayout } from '~/generated/graphql';
export const useSetViewTypeFromLayoutOptionsMenu = () => {
const { updateCurrentView } = useUpdateCurrentView();
const setRecordIndexViewType = useSetRecoilState(recordIndexViewTypeState);
const isSwitchingToKanbanViewTypeCallbackState =
useRecoilComponentCallbackState(isSwitchingToKanbanViewTypeComponentState);
const { availableFieldsForKanban } = useGetAvailableFieldsForKanban();
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const { loadRecordIndexStates } = useLoadRecordIndexStates();
const { createViewGroups } = usePersistViewGroupRecords();
const { availableFieldsForCalendar } = useGetAvailableFieldsForCalendar();
const createViewGroupAssociatedWithKanbanField = useCallback(
async (randomFieldForKanban: string, currentViewId: string) => {
const viewGroupsToCreate =
objectMetadataItem.fields
?.find((field) => field.id === randomFieldForKanban)
?.options?.map(
(option, index) =>
({
id: v4(),
__typename: 'ViewGroup',
fieldValue: option.value,
isVisible: true,
position: index,
}) satisfies ViewGroup,
) ?? [];
viewGroupsToCreate.push({
__typename: 'ViewGroup',
id: v4(),
fieldValue: '',
position: viewGroupsToCreate.length,
isVisible: true,
} satisfies ViewGroup);
await createViewGroups({
createCoreViewGroupInputs: {
inputs: viewGroupsToCreate.map(({ __typename, ...viewGroup }) => ({
...viewGroup,
viewId: currentViewId,
})),
},
});
return viewGroupsToCreate;
},
[objectMetadataItem, createViewGroups],
);
const setAndPersistViewType = useRecoilCallback(
({ snapshot, set }) =>
async (viewType: ViewType) => {
@@ -104,20 +64,20 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => {
switch (viewType) {
case ViewType.Kanban: {
set(isSwitchingToKanbanViewTypeCallbackState, true);
if (availableFieldsForKanban.length === 0) {
throw new Error('No fields for kanban - should not happen');
}
if (currentView.viewGroups.length === 0) {
const viewGroups = await createViewGroupAssociatedWithKanbanField(
availableFieldsForKanban[0].id,
currentView.id,
);
loadRecordIndexStates(
{ ...currentView, viewGroups },
objectMetadataItem,
);
const mainGroupByFieldMetadataId = availableFieldsForKanban[0].id;
updateCurrentViewParams.mainGroupByFieldMetadataId =
mainGroupByFieldMetadataId;
if (shouldChangeIcon(currentView.icon, currentView.type)) {
updateCurrentViewParams.icon =
viewTypeIconMapping(viewType).displayName;
}
setRecordIndexViewType(viewType);
set(coreViewsState, [
...existingCoreViews.filter(
@@ -126,16 +86,19 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => {
{
...currentCoreView,
type: convertViewTypeToCore(viewType),
mainGroupByFieldMetadataId,
},
]);
if (shouldChangeIcon(currentView.icon, currentView.type)) {
updateCurrentViewParams.icon =
viewTypeIconMapping(viewType).displayName;
}
return await updateCurrentView(updateCurrentViewParams);
await updateCurrentView(updateCurrentViewParams);
return;
}
case ViewType.Table: {
if (shouldChangeIcon(currentView.icon, currentView.type)) {
updateCurrentViewParams.icon =
viewTypeIconMapping(viewType).displayName;
}
updateCurrentViewParams.mainGroupByFieldMetadataId = null;
await updateCurrentView(updateCurrentViewParams);
setRecordIndexViewType(viewType);
set(coreViewsState, [
...existingCoreViews.filter(
@@ -143,15 +106,11 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => {
),
{
...currentCoreView,
mainGroupByFieldMetadataId: null,
type: convertViewTypeToCore(viewType),
},
]);
if (shouldChangeIcon(currentView.icon, currentView.type)) {
updateCurrentViewParams.icon =
viewTypeIconMapping(viewType).displayName;
}
return await updateCurrentView(updateCurrentViewParams);
return;
}
case ViewType.Calendar: {
if (availableFieldsForCalendar.length === 0) {
@@ -167,6 +126,7 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => {
),
{
...currentCoreView,
mainGroupByFieldMetadataId: null,
type: convertViewTypeToCore(viewType),
calendarLayout: ViewCalendarLayout.MONTH,
calendarFieldMetadataId,
@@ -190,6 +150,7 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => {
updateCurrentViewParams.calendarLayout = ViewCalendarLayout.MONTH;
updateCurrentViewParams.calendarFieldMetadataId =
calendarFieldMetadataId;
updateCurrentViewParams.mainGroupByFieldMetadataId = null;
return await updateCurrentView(updateCurrentViewParams);
}
default: {
@@ -198,13 +159,13 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => {
}
},
[
isSwitchingToKanbanViewTypeCallbackState,
availableFieldsForKanban,
objectMetadataItem,
updateCurrentView,
setRecordIndexViewType,
createViewGroupAssociatedWithKanbanField,
loadRecordIndexStates,
updateCurrentView,
availableFieldsForCalendar,
loadRecordIndexStates,
objectMetadataItem,
],
);
@@ -1,5 +1,6 @@
import { useTriggerRecordBoardFetchMore } from '@/object-record/record-board/hooks/useTriggerRecordBoardFetchMore';
import { useTriggerRecordBoardInitialQuery } from '@/object-record/record-board/hooks/useTriggerRecordBoardInitialQuery';
import { isSwitchingToKanbanViewTypeComponentState } from '@/object-record/record-board/states/isSwitchingToKanbanViewTypeComponentState';
import { lastRecordBoardQueryIdentifierComponentState } from '@/object-record/record-board/states/lastRecordBoardQueryIdentifierComponentState';
import { recordBoardCurrentGroupByQueryOffsetComponentState } from '@/object-record/record-board/states/recordBoardCurrentGroupByQueryOffsetComponentState';
import { recordBoardIsFetchingMoreComponentState } from '@/object-record/record-board/states/recordBoardIsFetchingMoreComponentState';
@@ -22,6 +23,11 @@ export const RecordBoardQueryEffect = () => {
const [lastRecordBoardQueryIdentifier, setLastRecordBoardQueryIdentifier] =
useRecoilComponentState(lastRecordBoardQueryIdentifierComponentState);
const [
isSwitchingToKanbanViewTypeCallbackState,
setIsSwitchingToKanbanViewTypeComponentState,
] = useRecoilComponentState(isSwitchingToKanbanViewTypeComponentState);
const [recordIndexRecordGroupsAreInInitialLoading] = useRecoilComponentState(
recordIndexRecordGroupsAreInInitialLoadingComponentState,
);
@@ -60,9 +66,10 @@ export const RecordBoardQueryEffect = () => {
useEffect(() => {
if (
!recordIndexRecordGroupsAreInInitialLoading &&
queryIdentifierHasChanged
(queryIdentifierHasChanged || isSwitchingToKanbanViewTypeCallbackState)
) {
triggerRecordBoardInitialQuery();
setIsSwitchingToKanbanViewTypeComponentState(false);
} else if (
!recordIndexRecordGroupsAreInInitialLoading &&
shouldFetchMore &&
@@ -82,6 +89,8 @@ export const RecordBoardQueryEffect = () => {
shouldFetchMore,
recordBoardIsFetchingMore,
triggerRecordBoardFetchMore,
isSwitchingToKanbanViewTypeCallbackState,
setIsSwitchingToKanbanViewTypeComponentState,
]);
return null;
@@ -0,0 +1,9 @@
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const isSwitchingToKanbanViewTypeComponentState =
createComponentState<boolean>({
key: 'isSwitchingToKanbanViewTypeComponentState',
componentInstanceContext: ContextStoreComponentInstanceContext,
defaultValue: false,
});
@@ -5,8 +5,8 @@ import { useSetRecordGroups } from '@/object-record/record-group/hooks/useSetRec
import { useLoadRecordIndexStates } from '@/object-record/record-index/hooks/useLoadRecordIndexStates';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { usePersistView } from '@/views/hooks/internal/usePersistView';
import { usePersistViewGroupRecords } from '@/views/hooks/internal/usePersistViewGroup';
import { useGetViewFromPrefetchState } from '@/views/hooks/useGetViewFromPrefetchState';
import { useRefreshCoreViewsByObjectMetadataId } from '@/views/hooks/useRefreshCoreViewsByObjectMetadataId';
import { type ViewGroup } from '@/views/types/ViewGroup';
import { convertCoreViewToView } from '@/views/utils/convertCoreViewToView';
import { useRecoilCallback } from 'recoil';
@@ -16,8 +16,6 @@ import { type CoreView } from '~/generated/graphql';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const useHandleRecordGroupField = () => {
const { deleteViewGroups } = usePersistViewGroupRecords();
const currentViewIdCallbackState = useRecoilComponentCallbackState(
contextStoreCurrentViewIdComponentState,
);
@@ -30,6 +28,8 @@ export const useHandleRecordGroupField = () => {
const { updateView } = usePersistView();
const { loadRecordIndexStates } = useLoadRecordIndexStates();
const { refreshCoreViewsByObjectMetadataId } =
useRefreshCoreViewsByObjectMetadataId();
const handleRecordGroupFieldChange = useRecoilCallback(
({ snapshot }) =>
@@ -127,6 +127,8 @@ export const useHandleRecordGroupField = () => {
viewGroups: newViewGroupsList,
objectMetadataItem,
});
await refreshCoreViewsByObjectMetadataId(objectMetadataItem.id);
},
[
currentViewIdCallbackState,
@@ -134,6 +136,7 @@ export const useHandleRecordGroupField = () => {
updateView,
setRecordGroupsFromViewGroups,
objectMetadataItem,
refreshCoreViewsByObjectMetadataId,
loadRecordIndexStates,
],
);
@@ -159,32 +162,14 @@ export const useHandleRecordGroupField = () => {
return;
}
await deleteViewGroups(
view.viewGroups.map((group) => ({
input: {
id: group.id,
},
})),
);
if (!isDefined(view.mainGroupByFieldMetadataId)) {
throw new Error('mainGroupByFieldMetadataId is required');
}
setRecordGroupsFromViewGroups({
viewId: view.id,
mainGroupByFieldMetadataId: view.mainGroupByFieldMetadataId,
viewGroups: [],
objectMetadataItem,
await updateView({
id: view.id,
input: {
mainGroupByFieldMetadataId: null,
},
});
},
[
deleteViewGroups,
currentViewIdCallbackState,
getViewFromPrefetchState,
setRecordGroupsFromViewGroups,
objectMetadataItem,
],
[currentViewIdCallbackState, getViewFromPrefetchState, updateView],
);
return { handleRecordGroupFieldChange, resetRecordGroupField };
@@ -3,8 +3,11 @@ import { useCallback } from 'react';
import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetadataErrorHandler';
import { type MetadataRequestResult } from '@/object-metadata/types/MetadataRequestResult.type';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useViewsSideEffectsOnViewGroups } from '@/views/hooks/useViewsSideEffectsOnViewGroups';
import { ApolloError } from '@apollo/client';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import {
type CreateCoreViewMutationVariables,
type DeleteCoreViewMutationVariables,
@@ -12,12 +15,15 @@ import {
useCreateCoreViewMutation,
useDeleteCoreViewMutation,
useUpdateCoreViewMutation,
ViewType,
} from '~/generated/graphql';
export const usePersistView = () => {
const [createCoreViewMutation] = useCreateCoreViewMutation();
const [updateCoreViewMutation] = useUpdateCoreViewMutation();
const [deleteCoreViewMutation] = useDeleteCoreViewMutation();
const { triggerViewGroupOptimisticEffectAtViewCreation } =
useViewsSideEffectsOnViewGroups();
const { handleMetadataError } = useMetadataErrorHandler();
const { enqueueErrorSnackBar } = useSnackBar();
@@ -25,14 +31,39 @@ export const usePersistView = () => {
const createView = useCallback(
async (
variables: CreateCoreViewMutationVariables,
objectMetadataItemId: string,
): Promise<
MetadataRequestResult<Awaited<ReturnType<typeof createCoreViewMutation>>>
> => {
try {
const newViewId = variables.input.id ?? v4();
if (variables.input.type === ViewType.KANBAN) {
triggerViewGroupOptimisticEffectAtViewCreation({
newViewId,
objectMetadataItemId: objectMetadataItemId,
mainGroupByFieldMetadataId:
variables.input.mainGroupByFieldMetadataId,
});
}
const result = await createCoreViewMutation({
variables,
variables: {
input: {
...variables.input,
id: newViewId,
},
},
});
const newView = result.data?.createCoreView;
if (!isDefined(newView)) {
return {
status: 'failed',
error: new Error('Failed to create view'),
};
}
return {
status: 'successful',
response: result,
@@ -52,7 +83,12 @@ export const usePersistView = () => {
};
}
},
[createCoreViewMutation, handleMetadataError, enqueueErrorSnackBar],
[
createCoreViewMutation,
triggerViewGroupOptimisticEffectAtViewCreation,
handleMetadataError,
enqueueErrorSnackBar,
],
);
const updateView = useCallback(
@@ -8,13 +8,7 @@ import { ApolloError } from '@apollo/client';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import {
type CreateManyCoreViewGroupsMutationVariables,
type DeleteCoreViewGroupMutationVariables,
type DestroyCoreViewGroupMutationVariables,
type UpdateCoreViewGroupMutationVariables,
useCreateManyCoreViewGroupsMutation,
useDeleteCoreViewGroupMutation,
useDestroyCoreViewGroupMutation,
useUpdateCoreViewGroupMutation,
} from '~/generated/graphql';
@@ -22,77 +16,11 @@ export const usePersistViewGroupRecords = () => {
const { triggerViewGroupOptimisticEffect } =
useTriggerViewGroupOptimisticEffect();
const [createManyCoreViewGroupsMutation] =
useCreateManyCoreViewGroupsMutation();
const [updateCoreViewGroupMutation] = useUpdateCoreViewGroupMutation();
const [deleteCoreViewGroupMutation] = useDeleteCoreViewGroupMutation();
const [destroyCoreViewGroupMutation] = useDestroyCoreViewGroupMutation();
const { handleMetadataError } = useMetadataErrorHandler();
const { enqueueErrorSnackBar } = useSnackBar();
const createViewGroups = useCallback(
async ({
createCoreViewGroupInputs,
}: {
createCoreViewGroupInputs: CreateManyCoreViewGroupsMutationVariables;
}): Promise<
MetadataRequestResult<Awaited<
ReturnType<typeof createManyCoreViewGroupsMutation>
> | null>
> => {
if (
!Array.isArray(createCoreViewGroupInputs.inputs) ||
createCoreViewGroupInputs.inputs.length === 0
) {
return {
status: 'successful',
response: null,
};
}
try {
const result = await createManyCoreViewGroupsMutation({
variables: createCoreViewGroupInputs,
update: (_cache, { data }) => {
const createdViewGroups = data?.createManyCoreViewGroups;
if (!isDefined(createdViewGroups)) {
return;
}
triggerViewGroupOptimisticEffect({
createdViewGroups,
});
},
});
return {
status: 'successful',
response: result,
};
} catch (error) {
if (error instanceof ApolloError) {
handleMetadataError(error, {
primaryMetadataName: 'viewGroup',
});
} else {
enqueueErrorSnackBar({ message: t`An error occurred.` });
}
return {
status: 'failed',
error,
};
}
},
[
triggerViewGroupOptimisticEffect,
createManyCoreViewGroupsMutation,
handleMetadataError,
enqueueErrorSnackBar,
],
);
const updateViewGroups = useCallback(
async (
updateCoreViewGroupInputs: UpdateCoreViewGroupMutationVariables[],
@@ -154,117 +82,7 @@ export const usePersistViewGroupRecords = () => {
],
);
const deleteViewGroups = useCallback(
async (
deleteCoreViewGroupInputs: DeleteCoreViewGroupMutationVariables[],
): Promise<
MetadataRequestResult<
Awaited<ReturnType<typeof deleteCoreViewGroupMutation>>[]
>
> => {
if (deleteCoreViewGroupInputs.length === 0) {
return {
status: 'successful',
response: [],
};
}
try {
const results = await Promise.all(
deleteCoreViewGroupInputs.map((variables) =>
deleteCoreViewGroupMutation({
variables,
update: (_cache, { data }) => {
const deletedViewGroup = data?.deleteCoreViewGroup;
if (!isDefined(deletedViewGroup)) {
return;
}
triggerViewGroupOptimisticEffect({
deletedViewGroups: [deletedViewGroup],
});
},
}),
),
);
return {
status: 'successful',
response: results,
};
} catch (error) {
if (error instanceof ApolloError) {
handleMetadataError(error, {
primaryMetadataName: 'viewGroup',
});
} else {
enqueueErrorSnackBar({ message: t`An error occurred.` });
}
return {
status: 'failed',
error,
};
}
},
[
triggerViewGroupOptimisticEffect,
deleteCoreViewGroupMutation,
handleMetadataError,
enqueueErrorSnackBar,
],
);
const destroyViewGroups = useCallback(
async (
destroyCoreViewGroupInputs: DestroyCoreViewGroupMutationVariables[],
): Promise<
MetadataRequestResult<
Awaited<ReturnType<typeof destroyCoreViewGroupMutation>>[]
>
> => {
if (destroyCoreViewGroupInputs.length === 0) {
return {
status: 'successful',
response: [],
};
}
try {
const results = await Promise.all(
destroyCoreViewGroupInputs.map((variables) =>
destroyCoreViewGroupMutation({
variables,
}),
),
);
return {
status: 'successful',
response: results,
};
} catch (error) {
if (error instanceof ApolloError) {
handleMetadataError(error, {
primaryMetadataName: 'viewGroup',
});
} else {
enqueueErrorSnackBar({ message: t`An error occurred.` });
}
return {
status: 'failed',
error,
};
}
},
[destroyCoreViewGroupMutation, handleMetadataError, enqueueErrorSnackBar],
);
return {
createViewGroups,
updateViewGroups,
deleteViewGroups,
destroyViewGroups,
};
};
@@ -13,11 +13,8 @@ import { usePersistViewFilterRecords } from '@/views/hooks/internal/usePersistVi
import { usePersistViewFilterGroupRecords } from '@/views/hooks/internal/usePersistViewFilterGroup';
import { usePersistViewSortRecords } from '@/views/hooks/internal/usePersistViewSort';
import { useRefreshCoreViewsByObjectMetadataId } from '@/views/hooks/useRefreshCoreViewsByObjectMetadataId';
import { useTriggerViewGroupOptimisticEffect } from '@/views/optimistic-effects/hooks/useTriggerViewGroupOptimisticEffect';
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 { ViewType } from '@/views/types/ViewType';
import { convertViewOpenRecordInToCore } from '@/views/utils/convertViewOpenRecordInToCore';
import { convertViewTypeToCore } from '@/views/utils/convertViewTypeToCore';
@@ -28,10 +25,7 @@ import { mapRecordSortToViewSort } from '@/views/utils/mapRecordSortToViewSort';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import {
type CoreViewGroup,
ViewCalendarLayout,
} from '~/generated-metadata/graphql';
import { ViewCalendarLayout } from '~/generated-metadata/graphql';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
@@ -50,9 +44,6 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
const { createViewSorts } = usePersistViewSortRecords();
const { triggerViewGroupOptimisticEffect } =
useTriggerViewGroupOptimisticEffect();
const { createViewFilters } = usePersistViewFilterRecords();
const { createViewFilterGroups } = usePersistViewFilterGroupRecords();
@@ -75,7 +66,7 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
);
const createViewFromCurrentView = useRecoilCallback(
({ snapshot, set }) =>
({ snapshot }) =>
async (
{
id,
@@ -120,53 +111,52 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
return undefined;
}
set(isPersistingViewFieldsState, true);
const viewType = type ?? sourceView.type;
const result = await createView({
input: {
id: id ?? v4(),
name: name ?? sourceView.name,
icon: icon ?? sourceView.icon,
key: null,
kanbanAggregateOperation: shouldCopyFiltersAndSortsAndAggregate
? sourceView.kanbanAggregateOperation
: undefined,
kanbanAggregateOperationFieldMetadataId:
shouldCopyFiltersAndSortsAndAggregate
? sourceView.kanbanAggregateOperationFieldMetadataId
const result = await createView(
{
input: {
id: id ?? v4(),
name: name ?? sourceView.name,
icon: icon ?? sourceView.icon,
key: null,
kanbanAggregateOperation: shouldCopyFiltersAndSortsAndAggregate
? sourceView.kanbanAggregateOperation
: undefined,
mainGroupByFieldMetadataId: shouldCopyFiltersAndSortsAndAggregate
? sourceView.mainGroupByFieldMetadataId
: mainGroupByFieldMetadataId,
type: convertViewTypeToCore(viewType),
objectMetadataId: sourceView.objectMetadataId,
openRecordIn: convertViewOpenRecordInToCore(
sourceView.openRecordIn,
),
anyFieldFilterValue: anyFieldFilterValue,
calendarLayout:
viewType === ViewType.Calendar
? ViewCalendarLayout.MONTH
: undefined,
calendarFieldMetadataId:
viewType === ViewType.Calendar
? calendarFieldMetadataId
: undefined,
visibility,
kanbanAggregateOperationFieldMetadataId:
shouldCopyFiltersAndSortsAndAggregate
? sourceView.kanbanAggregateOperationFieldMetadataId
: undefined,
mainGroupByFieldMetadataId: shouldCopyFiltersAndSortsAndAggregate
? sourceView.mainGroupByFieldMetadataId
: mainGroupByFieldMetadataId,
type: convertViewTypeToCore(viewType),
objectMetadataId: sourceView.objectMetadataId,
openRecordIn: convertViewOpenRecordInToCore(
sourceView.openRecordIn,
),
anyFieldFilterValue: anyFieldFilterValue,
calendarLayout:
viewType === ViewType.Calendar
? ViewCalendarLayout.MONTH
: undefined,
calendarFieldMetadataId:
viewType === ViewType.Calendar
? calendarFieldMetadataId
: undefined,
visibility,
},
},
});
objectMetadataItem.id,
);
if (result.status === 'failed') {
set(isPersistingViewFieldsState, false);
return undefined;
}
const newViewId = result.response.data?.createCoreView.id;
if (isUndefinedOrNull(newViewId)) {
set(isPersistingViewFieldsState, false);
throw new Error('Failed to create view');
}
@@ -181,60 +171,9 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
});
if (fieldResult.status === 'failed') {
set(isPersistingViewFieldsState, false);
return undefined;
}
if (type === ViewType.Kanban) {
if (
!isDefined(mainGroupByFieldMetadataId) ||
mainGroupByFieldMetadataId === ''
) {
throw new Error('Kanban view must have a kanban field');
}
const viewGroupsToCreate =
objectMetadataItem.fields
?.find((field) => field.id === mainGroupByFieldMetadataId)
?.options?.map(
(option, index) =>
({
id: v4(),
__typename: 'ViewGroup',
fieldValue: option.value,
isVisible: true,
position: index,
}) satisfies ViewGroup,
) ?? [];
if (
objectMetadataItem.fields.find(
(field) => field.id === mainGroupByFieldMetadataId,
)?.isNullable === true
) {
viewGroupsToCreate.push({
__typename: 'ViewGroup',
id: v4(),
fieldValue: '',
position: viewGroupsToCreate.length,
isVisible: true,
} satisfies ViewGroup);
}
triggerViewGroupOptimisticEffect({
createdViewGroups: viewGroupsToCreate.map(
({ __typename, ...viewGroup }) =>
({
...viewGroup,
viewId: newViewId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
}) as Omit<CoreViewGroup, 'workspaceId'>,
),
});
}
if (shouldCopyFiltersAndSortsAndAggregate === true) {
const viewFilterGroupsToCopy = currentRecordFilterGroups.map(
(recordFilterGroup) =>
@@ -285,7 +224,6 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
const filterResult = await createViewFilters(createViewFilterInputs);
if (filterResult.status === 'failed') {
set(isPersistingViewFieldsState, false);
return undefined;
}
@@ -294,24 +232,21 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => {
await refreshCoreViewsByObjectMetadataId(objectMetadataItem.id);
set(isPersistingViewFieldsState, false);
return newViewId;
},
[
currentViewIdCallbackState,
createViewFields,
createView,
anyFieldFilterValue,
objectMetadataItem.fields,
objectMetadataItem.id,
objectMetadataItem,
createViewFields,
refreshCoreViewsByObjectMetadataId,
currentRecordFilterGroups,
currentRecordFilters,
currentRecordSorts,
createViewFilterGroups,
createViewFilters,
createViewSorts,
refreshCoreViewsByObjectMetadataId,
triggerViewGroupOptimisticEffect,
],
);
@@ -5,7 +5,6 @@ import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-
import { usePersistViewField } from '@/views/hooks/internal/usePersistViewField';
import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges';
import { useGetViewFromPrefetchState } from '@/views/hooks/useGetViewFromPrefetchState';
import { isPersistingViewFieldsState } from '@/views/states/isPersistingViewFieldsState';
import { type ViewField } from '@/views/types/ViewField';
import {
type CreateViewFieldInput,
@@ -25,7 +24,7 @@ export const useSaveCurrentViewFields = () => {
);
const saveViewFields = useRecoilCallback(
({ set, snapshot }) =>
({ snapshot }) =>
async (viewFieldsToSave: Omit<ViewField, 'definition'>[]) => {
if (!canPersistChanges) {
return;
@@ -39,8 +38,6 @@ export const useSaveCurrentViewFields = () => {
return;
}
set(isPersistingViewFieldsState, true);
const view = getViewFromPrefetchState(currentViewId);
if (isUndefinedOrNull(view)) {
@@ -127,8 +124,6 @@ export const useSaveCurrentViewFields = () => {
createViewFields({ inputs: viewFieldsToCreate }),
updateViewFields(viewFieldsToUpdate),
]);
set(isPersistingViewFieldsState, false);
},
[
canPersistChanges,
@@ -12,7 +12,7 @@ import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const useSaveCurrentViewGroups = () => {
const { canPersistChanges } = useCanPersistViewChanges();
const { createViewGroups, updateViewGroups } = usePersistViewGroupRecords();
const { updateViewGroups } = usePersistViewGroupRecords();
const { getViewFromPrefetchState } = useGetViewFromPrefetchState();
@@ -150,33 +150,14 @@ export const useSaveCurrentViewGroups = () => {
})
.filter(isDefined);
const viewGroupsToCreate = viewGroupsToSave.filter(
(viewFieldToSave) =>
!currentViewGroups.some(
(currentViewGroup) =>
currentViewGroup.fieldValue === viewFieldToSave.fieldValue,
),
);
if (!isDefined(view.mainGroupByFieldMetadataId)) {
throw new Error('mainGroupByFieldMetadataId is required');
}
await Promise.all([
createViewGroups({
createCoreViewGroupInputs: {
inputs: viewGroupsToCreate.map((viewGroupToCreate) => ({
...viewGroupToCreate,
viewId: view.id,
})),
},
}),
updateViewGroups(viewGroupsToUpdate),
]);
await updateViewGroups(viewGroupsToUpdate);
},
[
canPersistChanges,
createViewGroups,
currentViewIdCallbackState,
getViewFromPrefetchState,
updateViewGroups,
@@ -1,13 +1,21 @@
import { useRecoilCallback } from 'recoil';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { useLoadRecordIndexStates } from '@/object-record/record-index/hooks/useLoadRecordIndexStates';
import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useCanPersistViewChanges } from '@/views/hooks/useCanPersistViewChanges';
import { useRefreshCoreViewsByObjectMetadataId } from '@/views/hooks/useRefreshCoreViewsByObjectMetadataId';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { type GraphQLView } from '@/views/types/GraphQLView';
import { type View } from '@/views/types/View';
import { type ViewGroup } from '@/views/types/ViewGroup';
import { type ViewType } from '@/views/types/ViewType';
import { convertUpdateViewInputToCore } from '@/views/utils/convertUpdateViewInputToCore';
import { useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { useUpdateCoreViewMutation } from '~/generated-metadata/graphql';
export const useUpdateCurrentView = () => {
@@ -15,15 +23,70 @@ export const useUpdateCurrentView = () => {
const currentViewIdCallbackState = useRecoilComponentCallbackState(
contextStoreCurrentViewIdComponentState,
);
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const { loadRecordIndexStates } = useLoadRecordIndexStates();
const setRecordIndexViewType = useSetRecoilState(recordIndexViewTypeState);
const [updateOneCoreView] = useUpdateCoreViewMutation();
const { refreshCoreViewsByObjectMetadataId } =
useRefreshCoreViewsByObjectMetadataId();
const [updateOneCoreView] = useUpdateCoreViewMutation();
const getViewGroupsToCreateAtViewUpdate = useMemo(() => {
return ({
existingView,
newMainGroupByFieldMetadataId,
}: {
existingView: View;
newMainGroupByFieldMetadataId?: string | null;
}) => {
if (newMainGroupByFieldMetadataId === undefined) {
return {};
}
let viewGroupsToCreate: ViewGroup[] = [];
if (
newMainGroupByFieldMetadataId !==
existingView.mainGroupByFieldMetadataId
) {
if (newMainGroupByFieldMetadataId !== null) {
viewGroupsToCreate =
objectMetadataItem.fields
?.find((field) => field.id === newMainGroupByFieldMetadataId)
?.options?.map(
(option, index) =>
({
id: v4(),
__typename: 'ViewGroup',
fieldValue: option.value,
isVisible: true,
position: index,
}) satisfies ViewGroup,
) ?? [];
if (
objectMetadataItem.fields.find(
(field) => field.id === newMainGroupByFieldMetadataId,
)?.isNullable === true
) {
viewGroupsToCreate.push({
__typename: 'ViewGroup',
id: v4(),
fieldValue: '',
position: viewGroupsToCreate.length,
isVisible: true,
} satisfies ViewGroup);
}
}
}
return { viewGroupsToCreate };
};
}, [objectMetadataItem.fields]);
const updateCurrentView = useRecoilCallback(
({ snapshot }) =>
async (view: Partial<GraphQLView>) => {
async (view: Partial<GraphQLView> & { type?: ViewType }) => {
if (!canPersistChanges) {
return;
}
@@ -53,6 +116,31 @@ export const useUpdateCurrentView = () => {
input,
},
});
if (
input.mainGroupByFieldMetadataId !== undefined &&
currentView.mainGroupByFieldMetadataId !==
input.mainGroupByFieldMetadataId
) {
const { viewGroupsToCreate } = getViewGroupsToCreateAtViewUpdate({
existingView: currentView,
newMainGroupByFieldMetadataId: input.mainGroupByFieldMetadataId,
});
loadRecordIndexStates(
{
...currentView,
mainGroupByFieldMetadataId: input.mainGroupByFieldMetadataId,
viewGroups: viewGroupsToCreate ?? [],
},
objectMetadataItem,
);
}
if (isDefined(view.type)) {
setRecordIndexViewType(view.type);
}
await refreshCoreViewsByObjectMetadataId(
currentView.objectMetadataId,
);
@@ -61,7 +149,11 @@ export const useUpdateCurrentView = () => {
[
canPersistChanges,
currentViewIdCallbackState,
getViewGroupsToCreateAtViewUpdate,
loadRecordIndexStates,
objectMetadataItem,
refreshCoreViewsByObjectMetadataId,
setRecordIndexViewType,
updateOneCoreView,
],
);
@@ -0,0 +1,87 @@
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useTriggerViewGroupOptimisticEffect } from '@/views/optimistic-effects/hooks/useTriggerViewGroupOptimisticEffect';
import { type ViewGroup } from '@/views/types/ViewGroup';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type CoreViewGroup } from '~/generated/graphql';
const useViewsSideEffectsOnViewGroups = () => {
const { triggerViewGroupOptimisticEffect } =
useTriggerViewGroupOptimisticEffect();
const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
const triggerViewGroupOptimisticEffectAtViewCreation = ({
newViewId,
mainGroupByFieldMetadataId,
objectMetadataItemId,
}: {
newViewId: string;
mainGroupByFieldMetadataId?: string | null;
objectMetadataItemId: string;
}) => {
if (!isDefined(mainGroupByFieldMetadataId)) {
return {};
}
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) => objectMetadataItem.id === objectMetadataItemId,
);
if (!isDefined(objectMetadataItem)) {
throw new Error('Object metadata item not found');
}
let viewGroupsToCreate: ViewGroup[] = [];
viewGroupsToCreate =
objectMetadataItem.fields
?.find((field) => field.id === mainGroupByFieldMetadataId)
?.options?.map(
(option, index) =>
({
id: v4(),
__typename: 'ViewGroup',
fieldValue: option.value,
isVisible: true,
position: index,
}) satisfies ViewGroup,
) ?? [];
if (
objectMetadataItem.fields.find(
(field) => field.id === mainGroupByFieldMetadataId,
)?.isNullable === true
) {
viewGroupsToCreate.push({
__typename: 'ViewGroup',
id: v4(),
fieldValue: '',
position: viewGroupsToCreate.length,
isVisible: true,
} satisfies ViewGroup);
}
triggerViewGroupOptimisticEffect({
createdViewGroups: viewGroupsToCreate.map(
({ __typename, ...viewGroup }) =>
({
...viewGroup,
viewId: newViewId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
deletedAt: null,
}) as Omit<CoreViewGroup, 'workspaceId'>,
),
});
return { viewGroupsToCreate };
};
return {
triggerViewGroupOptimisticEffectAtViewCreation,
};
};
export { useViewsSideEffectsOnViewGroups };
@@ -1,5 +0,0 @@
import { createState } from 'twenty-ui/utilities';
export const isPersistingViewFieldsState = createState<boolean>({
key: 'isPersistingViewFieldsState',
defaultValue: false,
});
@@ -46,5 +46,8 @@ export const convertUpdateViewInputToCore = (
calendarFieldMetadataId: view.calendarFieldMetadataId,
}),
...(isDefined(view.visibility) && { visibility: view.visibility }),
...(view.mainGroupByFieldMetadataId !== undefined && {
mainGroupByFieldMetadataId: view.mainGroupByFieldMetadataId,
}),
};
};