Batch create, update, and delete navigation menu items (#18882)

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Abdul Rahman
2026-03-25 01:54:47 +05:30
committed by GitHub
parent 0af7760441
commit 37640521d5
48 changed files with 2175 additions and 565 deletions
@@ -3395,8 +3395,11 @@ input UsageAnalyticsInput {
type Mutation {
addQueryToEventStream(input: AddQuerySubscriptionInput!): Boolean!
removeQueryFromEventStream(input: RemoveQueryFromEventStreamInput!): Boolean!
createManyNavigationMenuItems(inputs: [CreateNavigationMenuItemInput!]!): [NavigationMenuItem!]!
createNavigationMenuItem(input: CreateNavigationMenuItemInput!): NavigationMenuItem!
updateManyNavigationMenuItems(inputs: [UpdateOneNavigationMenuItemInput!]!): [NavigationMenuItem!]!
updateNavigationMenuItem(input: UpdateOneNavigationMenuItemInput!): NavigationMenuItem!
deleteManyNavigationMenuItems(ids: [UUID!]!): [NavigationMenuItem!]!
deleteNavigationMenuItem(id: UUID!): NavigationMenuItem!
uploadAIChatFile(file: Upload!): FileWithSignedUrl!
uploadWorkflowFile(file: Upload!): FileWithSignedUrl!
@@ -2849,8 +2849,11 @@ export type EventLogTable = 'WORKSPACE_EVENT' | 'PAGEVIEW' | 'OBJECT_EVENT' | 'U
export interface Mutation {
addQueryToEventStream: Scalars['Boolean']
removeQueryFromEventStream: Scalars['Boolean']
createManyNavigationMenuItems: NavigationMenuItem[]
createNavigationMenuItem: NavigationMenuItem
updateManyNavigationMenuItems: NavigationMenuItem[]
updateNavigationMenuItem: NavigationMenuItem
deleteManyNavigationMenuItems: NavigationMenuItem[]
deleteNavigationMenuItem: NavigationMenuItem
uploadAIChatFile: FileWithSignedUrl
uploadWorkflowFile: FileWithSignedUrl
@@ -6099,8 +6102,11 @@ export interface UsageAnalyticsInput {periodStart?: (Scalars['DateTime'] | null)
export interface MutationGenqlSelection{
addQueryToEventStream?: { __args: {input: AddQuerySubscriptionInput} }
removeQueryFromEventStream?: { __args: {input: RemoveQueryFromEventStreamInput} }
createManyNavigationMenuItems?: (NavigationMenuItemGenqlSelection & { __args: {inputs: CreateNavigationMenuItemInput[]} })
createNavigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {input: CreateNavigationMenuItemInput} })
updateManyNavigationMenuItems?: (NavigationMenuItemGenqlSelection & { __args: {inputs: UpdateOneNavigationMenuItemInput[]} })
updateNavigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {input: UpdateOneNavigationMenuItemInput} })
deleteManyNavigationMenuItems?: (NavigationMenuItemGenqlSelection & { __args: {ids: Scalars['UUID'][]} })
deleteNavigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {id: Scalars['UUID']} })
uploadAIChatFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })
uploadWorkflowFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })
@@ -7161,6 +7161,15 @@ export default {
]
}
],
"createManyNavigationMenuItems": [
145,
{
"inputs": [
369,
"[CreateNavigationMenuItemInput!]!"
]
}
],
"createNavigationMenuItem": [
145,
{
@@ -7170,6 +7179,15 @@ export default {
]
}
],
"updateManyNavigationMenuItems": [
145,
{
"inputs": [
370,
"[UpdateOneNavigationMenuItemInput!]!"
]
}
],
"updateNavigationMenuItem": [
145,
{
@@ -7179,6 +7197,15 @@ export default {
]
}
],
"deleteManyNavigationMenuItems": [
145,
{
"ids": [
3,
"[UUID!]!"
]
}
],
"deleteNavigationMenuItem": [
145,
{
File diff suppressed because one or more lines are too long
@@ -1,7 +1,11 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useHeadlessCommandContextApi } from '@/command-menu-item/engine-command/hooks/useHeadlessCommandContextApi';
import { useCreateNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useCreateNavigationMenuItem';
import { isDefined } from 'twenty-shared/utils';
import { useCreateManyNavigationMenuItems } from '@/navigation-menu-item/common/hooks/useCreateManyNavigationMenuItems';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData';
export const AddToFavoritesSingleRecordCommand = () => {
const { objectMetadataItem, selectedRecords } =
@@ -13,14 +17,34 @@ export const AddToFavoritesSingleRecordCommand = () => {
throw new Error('Object metadata item is required to add to favorites');
}
const { createNavigationMenuItem } = useCreateNavigationMenuItem();
const { createManyNavigationMenuItems } = useCreateManyNavigationMenuItems();
const { navigationMenuItems, currentWorkspaceMemberId } =
useNavigationMenuItemsData();
const handleExecute = () => {
if (!isDefined(selectedRecord)) {
return;
}
createNavigationMenuItem(selectedRecord, objectMetadataItem.nameSingular);
const relevantItems = navigationMenuItems.filter(
(item) => !isDefined(item.folderId) && isDefined(item.userWorkspaceId),
);
const maxPosition = Math.max(
...relevantItems.map((item) => item.position),
0,
);
createManyNavigationMenuItems([
{
id: uuidv4(),
type: NavigationMenuItemType.RECORD,
targetRecordId: selectedRecord.id,
targetObjectMetadataId: objectMetadataItem.id,
userWorkspaceId: currentWorkspaceMemberId,
position: maxPosition + 1,
},
]);
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
@@ -1,8 +1,9 @@
import { isDefined } from 'twenty-shared/utils';
import { HeadlessEngineCommandWrapperEffect } from '@/command-menu-item/engine-command/components/HeadlessEngineCommandWrapperEffect';
import { useHeadlessCommandContextApi } from '@/command-menu-item/engine-command/hooks/useHeadlessCommandContextApi';
import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useDeleteNavigationMenuItem';
import { useDeleteManyNavigationMenuItems } from '@/navigation-menu-item/common/hooks/useDeleteManyNavigationMenuItems';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData';
import { isDefined } from 'twenty-shared/utils';
export const RemoveFromFavoritesSingleRecordCommand = () => {
const { selectedRecords, objectMetadataItem } =
@@ -19,7 +20,7 @@ export const RemoveFromFavoritesSingleRecordCommand = () => {
const { navigationMenuItems, workspaceNavigationMenuItems } =
useNavigationMenuItemsData();
const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem();
const { deleteManyNavigationMenuItems } = useDeleteManyNavigationMenuItems();
const foundNavigationMenuItem = [
...navigationMenuItems,
@@ -35,7 +36,7 @@ export const RemoveFromFavoritesSingleRecordCommand = () => {
return;
}
deleteNavigationMenuItem(foundNavigationMenuItem.id);
deleteManyNavigationMenuItems([foundNavigationMenuItem.id]);
};
return <HeadlessEngineCommandWrapperEffect execute={handleExecute} />;
@@ -1,26 +1,42 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { Command } from '@/command-menu-item/display/components/Command';
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useCreateNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useCreateNavigationMenuItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { isDefined } from 'twenty-shared/utils';
import { useCreateManyNavigationMenuItems } from '@/navigation-menu-item/common/hooks/useCreateManyNavigationMenuItems';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData';
export const AddToFavoritesSingleRecordCommand = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
const recordId = useSelectedRecordIdOrThrow();
const { createNavigationMenuItem } = useCreateNavigationMenuItem();
const recordStore = useAtomFamilyStateValue(recordStoreFamilyState, recordId);
const { createManyNavigationMenuItems } = useCreateManyNavigationMenuItems();
const { navigationMenuItems, currentWorkspaceMemberId } =
useNavigationMenuItemsData();
const handleClick = () => {
if (!isDefined(recordStore)) {
return;
}
const relevantItems = navigationMenuItems.filter(
(item) => !isDefined(item.folderId) && isDefined(item.userWorkspaceId),
);
createNavigationMenuItem(recordStore, objectMetadataItem.nameSingular);
const maxPosition = Math.max(
...relevantItems.map((item) => item.position),
0,
);
createManyNavigationMenuItems([
{
id: uuidv4(),
type: NavigationMenuItemType.RECORD,
targetRecordId: recordId,
targetObjectMetadataId: objectMetadataItem.id,
userWorkspaceId: currentWorkspaceMemberId,
position: maxPosition + 1,
},
]);
};
return <Command onClick={handleClick} />;
@@ -1,9 +1,10 @@
import { isDefined } from 'twenty-shared/utils';
import { Command } from '@/command-menu-item/display/components/Command';
import { useSelectedRecordIdOrThrow } from '@/command-menu-item/record/single-record/hooks/useSelectedRecordIdOrThrow';
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useDeleteNavigationMenuItem';
import { useDeleteManyNavigationMenuItems } from '@/navigation-menu-item/common/hooks/useDeleteManyNavigationMenuItems';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData';
import { isDefined } from 'twenty-shared/utils';
export const RemoveFromFavoritesSingleRecordCommand = () => {
const recordId = useSelectedRecordIdOrThrow();
@@ -12,7 +13,7 @@ export const RemoveFromFavoritesSingleRecordCommand = () => {
const { navigationMenuItems, workspaceNavigationMenuItems } =
useNavigationMenuItemsData();
const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem();
const { deleteManyNavigationMenuItems } = useDeleteManyNavigationMenuItems();
const foundNavigationMenuItem = [
...navigationMenuItems,
@@ -28,7 +29,7 @@ export const RemoveFromFavoritesSingleRecordCommand = () => {
return;
}
deleteNavigationMenuItem(foundNavigationMenuItem.id);
deleteManyNavigationMenuItems([foundNavigationMenuItem.id]);
};
return <Command onClick={handleClick} />;
@@ -1,10 +1,12 @@
import { gql } from '@apollo/client';
import { NAVIGATION_MENU_ITEM_QUERY_FRAGMENT } from '@/navigation-menu-item/common/graphql/fragments/navigationMenuItemQueryFragment';
export const CREATE_NAVIGATION_MENU_ITEM = gql`
export const CREATE_MANY_NAVIGATION_MENU_ITEMS = gql`
${NAVIGATION_MENU_ITEM_QUERY_FRAGMENT}
mutation CreateNavigationMenuItem($input: CreateNavigationMenuItemInput!) {
createNavigationMenuItem(input: $input) {
mutation CreateManyNavigationMenuItems(
$inputs: [CreateNavigationMenuItemInput!]!
) {
createManyNavigationMenuItems(inputs: $inputs) {
...NavigationMenuItemQueryFields
}
}
@@ -1,10 +1,10 @@
import { gql } from '@apollo/client';
import { NAVIGATION_MENU_ITEM_QUERY_FRAGMENT } from '@/navigation-menu-item/common/graphql/fragments/navigationMenuItemQueryFragment';
export const DELETE_NAVIGATION_MENU_ITEM = gql`
export const DELETE_MANY_NAVIGATION_MENU_ITEMS = gql`
${NAVIGATION_MENU_ITEM_QUERY_FRAGMENT}
mutation DeleteNavigationMenuItem($id: UUID!) {
deleteNavigationMenuItem(id: $id) {
mutation DeleteManyNavigationMenuItems($ids: [UUID!]!) {
deleteManyNavigationMenuItems(ids: $ids) {
...NavigationMenuItemQueryFields
}
}
@@ -1,10 +1,12 @@
import { gql } from '@apollo/client';
import { NAVIGATION_MENU_ITEM_QUERY_FRAGMENT } from '@/navigation-menu-item/common/graphql/fragments/navigationMenuItemQueryFragment';
export const UPDATE_NAVIGATION_MENU_ITEM = gql`
export const UPDATE_MANY_NAVIGATION_MENU_ITEMS = gql`
${NAVIGATION_MENU_ITEM_QUERY_FRAGMENT}
mutation UpdateNavigationMenuItem($input: UpdateOneNavigationMenuItemInput!) {
updateNavigationMenuItem(input: $input) {
mutation UpdateManyNavigationMenuItems(
$inputs: [UpdateOneNavigationMenuItemInput!]!
) {
updateManyNavigationMenuItems(inputs: $inputs) {
...NavigationMenuItemQueryFields
}
}
@@ -0,0 +1,85 @@
import { useMutation } from '@apollo/client/react';
import { isDefined } from 'twenty-shared/utils';
import {
type CreateNavigationMenuItemInput,
CreateManyNavigationMenuItemsDocument,
type NavigationMenuItem,
} from '~/generated-metadata/graphql';
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
const buildOptimisticNavigationMenuItem = (
input: CreateNavigationMenuItemInput & { id: string },
): NavigationMenuItem => ({
id: input.id,
type: input.type,
position: input.position ?? 0,
userWorkspaceId: input.userWorkspaceId ?? null,
targetRecordId: input.targetRecordId ?? null,
targetObjectMetadataId: input.targetObjectMetadataId ?? null,
viewId: input.viewId ?? null,
folderId: input.folderId ?? null,
name: input.name ?? null,
link: input.link ?? null,
icon: input.icon ?? null,
color: input.color ?? null,
applicationId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
export const useCreateManyNavigationMenuItems = () => {
const { addToDraft, removeFromDraft, applyChanges } =
useUpdateMetadataStoreDraft();
const [createManyNavigationMenuItemsMutation] = useMutation(
CreateManyNavigationMenuItemsDocument,
);
const createManyNavigationMenuItems = async (
inputs: CreateNavigationMenuItemInput[],
): Promise<NavigationMenuItem[]> => {
if (inputs.length === 0) {
return [];
}
const inputsWithIds = inputs.filter(
(input): input is CreateNavigationMenuItemInput & { id: string } =>
isDefined(input.id),
);
if (inputsWithIds.length > 0) {
addToDraft({
key: 'navigationMenuItems',
items: inputsWithIds.map(buildOptimisticNavigationMenuItem),
});
applyChanges();
}
try {
const result = await createManyNavigationMenuItemsMutation({
variables: { inputs },
});
const createdItems = result.data?.createManyNavigationMenuItems ?? [];
if (createdItems.length > 0) {
addToDraft({ key: 'navigationMenuItems', items: createdItems });
applyChanges();
}
return createdItems;
} catch (error) {
if (inputsWithIds.length > 0) {
removeFromDraft({
key: 'navigationMenuItems',
itemIds: inputsWithIds.map((input) => input.id),
});
applyChanges();
}
throw error;
}
};
return { createManyNavigationMenuItems };
};
@@ -1,136 +0,0 @@
import { useMutation } from '@apollo/client/react';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import {
type CreateNavigationMenuItemInput,
CreateNavigationMenuItemDocument,
type NavigationMenuItem,
} from '~/generated-metadata/graphql';
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
const buildOptimisticNavigationMenuItem = (
input: CreateNavigationMenuItemInput & { id: string },
): NavigationMenuItem => ({
id: input.id,
type: input.type,
position: input.position ?? 0,
userWorkspaceId: input.userWorkspaceId ?? null,
targetRecordId: input.targetRecordId ?? null,
targetObjectMetadataId: input.targetObjectMetadataId ?? null,
viewId: input.viewId ?? null,
folderId: input.folderId ?? null,
name: input.name ?? null,
link: input.link ?? null,
icon: input.icon ?? null,
color: input.color ?? null,
applicationId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
export const useCreateNavigationMenuItem = () => {
const { navigationMenuItems, currentWorkspaceMemberId } =
useNavigationMenuItemsData();
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const { addToDraft, applyChanges } = useUpdateMetadataStoreDraft();
const [createNavigationMenuItemMutation] = useMutation(
CreateNavigationMenuItemDocument,
);
const createNavigationMenuItem = async (
targetRecord: ObjectRecord,
targetObjectNameSingular: string,
folderId?: string,
) => {
const isView = targetObjectNameSingular === 'view';
const id = uuidv4();
const relevantItems = folderId
? navigationMenuItems.filter((item) => item.folderId === folderId)
: navigationMenuItems.filter(
(item) =>
!isDefined(item.folderId) && isDefined(item.userWorkspaceId),
);
const maxPosition = Math.max(
...relevantItems.map((item) => item.position),
0,
);
const position = maxPosition + 1;
if (isView) {
const input: CreateNavigationMenuItemInput = {
id,
type: NavigationMenuItemType.VIEW,
viewId: targetRecord.id,
userWorkspaceId: currentWorkspaceMemberId,
folderId,
position,
};
addToDraft({
key: 'navigationMenuItems',
items: [buildOptimisticNavigationMenuItem({ ...input, id })],
});
applyChanges();
const result = await createNavigationMenuItemMutation({
variables: { input },
});
const created = result.data?.createNavigationMenuItem;
if (isDefined(created)) {
addToDraft({ key: 'navigationMenuItems', items: [created] });
applyChanges();
}
} else {
const objectMetadataItem = objectMetadataItems.find(
(item) => item.nameSingular === targetObjectNameSingular,
);
if (!isDefined(objectMetadataItem)) {
throw new Error(
`Object metadata item not found for nameSingular: ${targetObjectNameSingular}`,
);
}
const input: CreateNavigationMenuItemInput = {
id,
type: NavigationMenuItemType.RECORD,
targetRecordId: targetRecord.id,
targetObjectMetadataId: objectMetadataItem.id,
userWorkspaceId: currentWorkspaceMemberId,
folderId,
position,
};
addToDraft({
key: 'navigationMenuItems',
items: [buildOptimisticNavigationMenuItem({ ...input, id })],
});
applyChanges();
const result = await createNavigationMenuItemMutation({
variables: { input },
});
const created = result.data?.createNavigationMenuItem;
if (isDefined(created)) {
addToDraft({ key: 'navigationMenuItems', items: [created] });
applyChanges();
}
}
};
return { createNavigationMenuItem };
};
@@ -0,0 +1,38 @@
import { useMutation } from '@apollo/client/react';
import { DeleteManyNavigationMenuItemsDocument } from '~/generated-metadata/graphql';
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/states/navigationMenuItemsSelector';
import { useStore } from 'jotai';
export const useDeleteManyNavigationMenuItems = () => {
const { removeFromDraft, replaceDraft, applyChanges } =
useUpdateMetadataStoreDraft();
const [deleteManyNavigationMenuItemsMutation] = useMutation(
DeleteManyNavigationMenuItemsDocument,
);
const store = useStore();
const deleteManyNavigationMenuItems = async (ids: string[]) => {
if (ids.length === 0) {
return;
}
const previousItems = store.get(navigationMenuItemsSelector.atom);
removeFromDraft({ key: 'navigationMenuItems', itemIds: ids });
applyChanges();
try {
await deleteManyNavigationMenuItemsMutation({ variables: { ids } });
} catch (error) {
replaceDraft('navigationMenuItems', previousItems);
applyChanges();
throw error;
}
};
return { deleteManyNavigationMenuItems };
};
@@ -1,23 +0,0 @@
import { useMutation } from '@apollo/client/react';
import { DeleteNavigationMenuItemDocument } from '~/generated-metadata/graphql';
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
export const useDeleteNavigationMenuItem = () => {
const { removeFromDraft, applyChanges } = useUpdateMetadataStoreDraft();
const [deleteNavigationMenuItemMutation] = useMutation(
DeleteNavigationMenuItemDocument,
);
const deleteNavigationMenuItem = async (id: string) => {
removeFromDraft({ key: 'navigationMenuItems', itemIds: [id] });
applyChanges();
await deleteNavigationMenuItemMutation({
variables: { id },
});
};
return { deleteNavigationMenuItem };
};
@@ -0,0 +1,59 @@
import { useMutation } from '@apollo/client/react';
import {
type NavigationMenuItem,
UpdateManyNavigationMenuItemsDocument,
type UpdateOneNavigationMenuItemInput,
} from '~/generated-metadata/graphql';
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/states/navigationMenuItemsSelector';
import { useStore } from 'jotai';
export const useUpdateManyNavigationMenuItems = () => {
const { updateInDraft, addToDraft, replaceDraft, applyChanges } =
useUpdateMetadataStoreDraft();
const [updateManyNavigationMenuItemsMutation] = useMutation(
UpdateManyNavigationMenuItemsDocument,
);
const store = useStore();
const updateManyNavigationMenuItems = async (
inputs: UpdateOneNavigationMenuItemInput[],
): Promise<NavigationMenuItem[]> => {
if (inputs.length === 0) {
return [];
}
const previousItems = store.get(navigationMenuItemsSelector.atom);
const optimisticItems = inputs.map(
({ id, update }) => ({ id, ...update }) as NavigationMenuItem,
);
updateInDraft('navigationMenuItems', optimisticItems);
applyChanges();
try {
const result = await updateManyNavigationMenuItemsMutation({
variables: { inputs },
});
const updatedItems = result.data?.updateManyNavigationMenuItems ?? [];
if (updatedItems.length > 0) {
addToDraft({ key: 'navigationMenuItems', items: updatedItems });
applyChanges();
}
return updatedItems;
} catch (error) {
replaceDraft('navigationMenuItems', previousItems);
applyChanges();
throw error;
}
};
return { updateManyNavigationMenuItems };
};
@@ -1,48 +0,0 @@
import { useMutation } from '@apollo/client/react';
import { isDefined } from 'twenty-shared/utils';
import {
type NavigationMenuItem,
type UpdateNavigationMenuItemInput,
type UpdateOneNavigationMenuItemInput,
UpdateNavigationMenuItemDocument,
} from '~/generated-metadata/graphql';
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
export const useUpdateNavigationMenuItem = () => {
const { addToDraft, applyChanges } = useUpdateMetadataStoreDraft();
const [updateNavigationMenuItemMutation] = useMutation(
UpdateNavigationMenuItemDocument,
);
const updateNavigationMenuItem = async (
input: UpdateNavigationMenuItemInput & { id: string },
) => {
const { id, ...update } = input;
addToDraft({
key: 'navigationMenuItems',
items: [{ id, ...update } as NavigationMenuItem],
});
applyChanges();
const updateOneInput: UpdateOneNavigationMenuItemInput = {
id,
update,
};
const result = await updateNavigationMenuItemMutation({
variables: { input: updateOneInput },
});
const updated = result.data?.updateNavigationMenuItem;
if (isDefined(updated)) {
addToDraft({ key: 'navigationMenuItems', items: [updated] });
applyChanges();
}
};
return { updateNavigationMenuItem };
};
@@ -3,7 +3,7 @@ import type {
NavigationMenuItem,
} from '~/generated-metadata/graphql';
import { isDefined } from 'twenty-shared/utils';
import { ensureAbsoluteUrl, isDefined } from 'twenty-shared/utils';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/isNavigationMenuItemFolder';
import { isNavigationMenuItemLink } from '@/navigation-menu-item/common/utils/isNavigationMenuItemLink';
@@ -14,6 +14,7 @@ export const buildCreateNavigationMenuItemInput = (
resolveFolderId: (draftFolderId: string) => string,
): CreateNavigationMenuItemInput => {
const input: CreateNavigationMenuItemInput = {
id: draftItem.id,
type: draftItem.type,
position: draftItem.position,
};
@@ -24,12 +25,7 @@ export const buildCreateNavigationMenuItemInput = (
} else if (isNavigationMenuItemLink(draftItem)) {
input.name = draftItem.name ?? 'Link';
const linkUrl = (draftItem.link ?? '').trim();
input.link =
linkUrl.startsWith('http://') || linkUrl.startsWith('https://')
? linkUrl
: linkUrl
? `https://${linkUrl}`
: undefined;
input.link = linkUrl ? ensureAbsoluteUrl(linkUrl) : undefined;
} else if (isNavigationMenuItemObject(draftItem)) {
input.targetObjectMetadataId =
draftItem.targetObjectMetadataId ?? undefined;
@@ -4,7 +4,7 @@ import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { isDefined } from 'twenty-shared/utils';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { useUpdateNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useUpdateNavigationMenuItem';
import { useUpdateManyNavigationMenuItems } from '@/navigation-menu-item/common/hooks/useUpdateManyNavigationMenuItems';
import { NAVIGATION_MENU_ITEM_SECTION_DROPPABLE_CONFIG } from '@/navigation-menu-item/common/constants/NavigationMenuItemSectionDroppableConfig';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/common/states/openNavigationMenuItemFolderIdsState';
@@ -29,7 +29,7 @@ export const useHandleNavigationMenuItemDragAndDrop = (
const { navigationMenuItems, workspaceNavigationMenuItems } =
useNavigationMenuItemsData();
const { navigationMenuItemsSorted } = useSortedNavigationMenuItems();
const { updateNavigationMenuItem } = useUpdateNavigationMenuItem();
const { updateManyNavigationMenuItems } = useUpdateManyNavigationMenuItems();
const setNavigationMenuItemsDraft = useSetAtomState(
navigationMenuItemsDraftState,
);
@@ -73,11 +73,12 @@ export const useHandleNavigationMenuItemDragAndDrop = (
}),
);
} else {
await updateNavigationMenuItem({
id: draggableId,
position: newPosition,
...folderUpdate,
});
await updateManyNavigationMenuItems([
{
id: draggableId,
update: { position: newPosition, ...folderUpdate },
},
]);
}
};
@@ -6,7 +6,7 @@ import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/common/co
import { NavigationMenuItemBackButton } from '@/navigation-menu-item/edit/components/NavigationMenuItemBackButton';
import { NavigationMenuItemIcon } from '@/navigation-menu-item/display/components/NavigationMenuItemIcon';
import { NavigationMenuItemSortableItem } from '@/navigation-menu-item/display/dnd/components/NavigationMenuItemSortableItem';
import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useDeleteNavigationMenuItem';
import { useDeleteManyNavigationMenuItems } from '@/navigation-menu-item/common/hooks/useDeleteManyNavigationMenuItems';
import { getNavigationMenuItemComputedLink } from '@/navigation-menu-item/display/utils/getNavigationMenuItemComputedLink';
import { getNavigationMenuItemLabel } from '@/navigation-menu-item/display/utils/getNavigationMenuItemLabel';
import { getNavigationMenuItemObjectNameSingular } from '@/navigation-menu-item/display/object/utils/getNavigationMenuItemObjectNameSingular';
@@ -29,7 +29,7 @@ export const NavigationMenuItemFolderContent = ({
}: NavigationMenuItemFolderContentProps) => {
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const views = useAtomStateValue(viewsSelector);
const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem();
const { deleteManyNavigationMenuItems } = useDeleteManyNavigationMenuItems();
const folderDroppableId = `${NavigationMenuItemDroppableIds.FAVORITE_FOLDER_PREFIX}${folderId}`;
@@ -76,7 +76,7 @@ export const NavigationMenuItemFolderContent = ({
Icon={IconHeartOff}
onClick={(e) => {
e.stopPropagation();
deleteNavigationMenuItem(navigationMenuItem.id);
deleteManyNavigationMenuItems([navigationMenuItem.id]);
}}
accent="tertiary"
/>
@@ -24,7 +24,7 @@ import { NavigationSections } from '@/navigation-menu-item/common/constants/Navi
import { NavigationDropTargetContext } from '@/navigation-menu-item/common/contexts/NavigationDropTargetContext';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/common/contexts/NavigationMenuItemDragContext';
import { SortableDropTargetRefContext } from '@/navigation-menu-item/common/contexts/SortableDropTargetRefContext';
import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useDeleteNavigationMenuItem';
import { useDeleteManyNavigationMenuItems } from '@/navigation-menu-item/common/hooks/useDeleteManyNavigationMenuItems';
import { addMenuItemInsertionContextState } from '@/navigation-menu-item/common/states/addMenuItemInsertionContextState';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import type { NavigationMenuItemSection } from '@/navigation-menu-item/common/types/NavigationMenuItemSection';
@@ -133,7 +133,7 @@ export const NavigationMenuItemFolderDnd = ({
NavigationDropTargetContext,
);
const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem();
const { deleteManyNavigationMenuItems } = useDeleteManyNavigationMenuItems();
const favoritesEdit = useFavoritesFolderEdit({
folderId,
folderName,
@@ -361,7 +361,9 @@ export const NavigationMenuItemFolderDnd = ({
Icon={IconHeartOff}
onClick={(event) => {
event.stopPropagation();
deleteNavigationMenuItem(navigationMenuItem.id);
deleteManyNavigationMenuItems([
navigationMenuItem.id,
]);
}}
accent="tertiary"
/>
@@ -1,17 +1,14 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useMutation } from '@apollo/client/react';
import { CreateNavigationMenuItemDocument } from '~/generated-metadata/graphql';
import { useCreateManyNavigationMenuItems } from '@/navigation-menu-item/common/hooks/useCreateManyNavigationMenuItems';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData';
export const useCreateNavigationMenuItemFolder = () => {
const { navigationMenuItems, currentWorkspaceMemberId } =
useNavigationMenuItemsData();
const [createNavigationMenuItemMutation] = useMutation(
CreateNavigationMenuItemDocument,
);
const { createManyNavigationMenuItems } = useCreateManyNavigationMenuItems();
const createNewNavigationMenuItemFolder = async (
name: string,
@@ -31,19 +28,17 @@ export const useCreateNavigationMenuItemFolder = () => {
? Math.min(...topLevelItems.map((item) => item.position))
: 1;
await createNavigationMenuItemMutation({
variables: {
input: {
type: NavigationMenuItemType.FOLDER,
name,
targetRecordId: null,
targetObjectMetadataId: null,
userWorkspaceId: currentWorkspaceMemberId,
folderId: null,
position: minPosition - 1,
},
await createManyNavigationMenuItems([
{
type: NavigationMenuItemType.FOLDER,
name,
targetRecordId: null,
targetObjectMetadataId: null,
userWorkspaceId: currentWorkspaceMemberId,
folderId: null,
position: minPosition - 1,
},
});
]);
};
return { createNewNavigationMenuItemFolder };
@@ -9,7 +9,7 @@ import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/common/constants/NavigationMenuItemDroppableIds';
import { NavigationSections } from '@/navigation-menu-item/common/constants/NavigationSections.constants';
import { NavigationMenuItemDragContext } from '@/navigation-menu-item/common/contexts/NavigationMenuItemDragContext';
import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useDeleteNavigationMenuItem';
import { useDeleteManyNavigationMenuItems } from '@/navigation-menu-item/common/hooks/useDeleteManyNavigationMenuItems';
import { isNavigationMenuItemFolderCreatingState } from '@/navigation-menu-item/common/states/isNavigationMenuItemFolderCreatingState';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/isNavigationMenuItemFolder';
import { NavigationMenuItemDisplay } from '@/navigation-menu-item/display/components/NavigationMenuItemDisplay';
@@ -51,7 +51,7 @@ const ORPHAN_DROPPABLE_ID =
export const FavoritesSection = () => {
const { navigationMenuItemsSorted } = useSortedNavigationMenuItems();
const { userNavigationMenuItemsByFolder } = useNavigationMenuItemsByFolder();
const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem();
const { deleteManyNavigationMenuItems } = useDeleteManyNavigationMenuItems();
const { isDragging } = useContext(NavigationMenuItemDragContext);
const favoritesDropDisabled = useIsDropDisabledForSection(false);
@@ -128,12 +128,12 @@ export const FavoritesSection = () => {
Icon={IconHeartOff}
onClick={(event) => {
event.stopPropagation();
deleteNavigationMenuItem(item.id);
deleteManyNavigationMenuItems([item.id]);
}}
accent="tertiary"
/>
),
[deleteNavigationMenuItem],
[deleteManyNavigationMenuItems],
);
if (
@@ -1,12 +1,12 @@
import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useDeleteNavigationMenuItem';
import { useDeleteManyNavigationMenuItems } from '@/navigation-menu-item/common/hooks/useDeleteManyNavigationMenuItems';
export const useDeleteNavigationMenuItemFolder = () => {
const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem();
const { deleteManyNavigationMenuItems } = useDeleteManyNavigationMenuItems();
const deleteNavigationMenuItemFolder = async (
folderId: string,
): Promise<void> => {
await deleteNavigationMenuItem(folderId);
await deleteManyNavigationMenuItems([folderId]);
};
return {
@@ -1,7 +1,7 @@
import { useUpdateNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useUpdateNavigationMenuItem';
import { useUpdateManyNavigationMenuItems } from '@/navigation-menu-item/common/hooks/useUpdateManyNavigationMenuItems';
export const useRenameNavigationMenuItemFolder = () => {
const { updateNavigationMenuItem } = useUpdateNavigationMenuItem();
const { updateManyNavigationMenuItems } = useUpdateManyNavigationMenuItems();
const renameNavigationMenuItemFolder = async (
folderId: string,
@@ -11,10 +11,9 @@ export const useRenameNavigationMenuItemFolder = () => {
return;
}
await updateNavigationMenuItem({
id: folderId,
name: newName,
});
await updateManyNavigationMenuItems([
{ id: folderId, update: { name: newName } },
]);
};
return {
@@ -1,29 +1,22 @@
import { useCallback } from 'react';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useMutation } from '@apollo/client/react';
import { CreateNavigationMenuItemDocument } from '~/generated-metadata/graphql';
import { useDeleteNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useDeleteNavigationMenuItem';
import { useUpdateNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useUpdateNavigationMenuItem';
import { useCreateManyNavigationMenuItems } from '@/navigation-menu-item/common/hooks/useCreateManyNavigationMenuItems';
import { useDeleteManyNavigationMenuItems } from '@/navigation-menu-item/common/hooks/useDeleteManyNavigationMenuItems';
import { useUpdateManyNavigationMenuItems } from '@/navigation-menu-item/common/hooks/useUpdateManyNavigationMenuItems';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/states/navigationMenuItemsSelector';
import { buildCreateNavigationMenuItemInput } from '@/navigation-menu-item/common/utils/buildCreateNavigationMenuItemInput';
import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/common/utils/filterWorkspaceNavigationMenuItems';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/isNavigationMenuItemFolder';
import { isNavigationMenuItemLink } from '@/navigation-menu-item/common/utils/isNavigationMenuItemLink';
import { orderFoldersForCreation } from '@/navigation-menu-item/common/utils/orderFoldersForCreation';
import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/states/navigationMenuItemsSelector';
import { buildUpdateInputsFromDraft } from '@/navigation-menu-item/edit/utils/buildUpdateInputsFromDraft';
import { getObjectMetadataColorUpdates } from '@/navigation-menu-item/edit/utils/getObjectMetadataColorUpdates';
import { useUpdateOneObjectMetadataItem } from '@/object-metadata/hooks/useUpdateOneObjectMetadataItem';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useStore } from 'jotai';
export const useSaveNavigationMenuItemsDraft = () => {
const { updateNavigationMenuItem } = useUpdateNavigationMenuItem();
const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem();
const [createNavigationMenuItemMutation] = useMutation(
CreateNavigationMenuItemDocument,
);
const { createManyNavigationMenuItems } = useCreateManyNavigationMenuItems();
const { deleteManyNavigationMenuItems } = useDeleteManyNavigationMenuItems();
const { updateManyNavigationMenuItems } = useUpdateManyNavigationMenuItems();
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
const store = useStore();
@@ -32,71 +25,47 @@ export const useSaveNavigationMenuItemsDraft = () => {
const draft = store.get(navigationMenuItemsDraftState.atom);
const currentItems = store.get(navigationMenuItemsSelector.atom);
if (!draft) return;
if (!draft) {
return;
}
const objectMetadataItems = store.get(objectMetadataItemsSelector.atom);
for (const draftItem of draft) {
if (draftItem.type !== NavigationMenuItemType.OBJECT) {
continue;
}
if (!isDefined(draftItem.targetObjectMetadataId)) {
continue;
}
if (!isDefined(draftItem.color)) {
continue;
}
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === draftItem.targetObjectMetadataId,
);
if (!isDefined(objectMetadataItem)) {
continue;
}
if (objectMetadataItem.color === draftItem.color) {
continue;
}
const colorUpdates = getObjectMetadataColorUpdates({
draft,
objectMetadataItems,
});
for (const { idToUpdate, color } of colorUpdates) {
await updateOneObjectMetadataItem({
idToUpdate: draftItem.targetObjectMetadataId,
updatePayload: { color: draftItem.color },
idToUpdate,
updatePayload: { color },
});
}
const workspaceItems = filterWorkspaceNavigationMenuItems(currentItems);
const topLevelWorkspace = workspaceItems.filter(
(item) => !isDefined(item.folderId),
);
const draftIds = new Set(draft.map((i) => i.id));
const draftIds = new Set(draft.map((item) => item.id));
const topLevelToDelete = topLevelWorkspace.filter(
(item) => !draftIds.has(item.id),
);
const folderIdsToDelete = new Set(
topLevelToDelete
.filter(isNavigationMenuItemFolder)
.map((item) => item.id),
);
const folderChildrenToDelete = currentItems.filter(
(item) =>
isDefined(item.folderId) && folderIdsToDelete.has(item.folderId),
const deleteAfterLayoutChangeIds = workspaceItems
.filter((workspaceItem) => !draftIds.has(workspaceItem.id))
.map((workspaceItem) => workspaceItem.id);
await deleteManyNavigationMenuItems(deleteAfterLayoutChangeIds);
const currentIds = new Set(workspaceItems.map((item) => item.id));
const workspaceItemsById = new Map(
workspaceItems.map((item) => [item.id, item]),
);
for (const item of folderChildrenToDelete) {
await deleteNavigationMenuItem(item.id);
}
for (const item of topLevelToDelete) {
await deleteNavigationMenuItem(item.id);
}
const itemsToCreate = draft.filter((item) => !currentIds.has(item.id));
const currentIds = new Set(workspaceItems.map((i) => i.id));
const workspaceItemsById = new Map(workspaceItems.map((i) => [i.id, i]));
const idsToCreate = draft.filter((item) => !currentIds.has(item.id));
const idsToRecreate = draft.filter((item) => {
const original = workspaceItemsById.get(item.id);
if (!original) return false;
if (!original) {
return false;
}
return (
original.viewId !== item.viewId ||
original.targetObjectMetadataId !== item.targetObjectMetadataId ||
@@ -104,124 +73,33 @@ export const useSaveNavigationMenuItemsDraft = () => {
);
});
for (const draftItem of idsToRecreate) {
await deleteNavigationMenuItem(draftItem.id);
}
const recreateIds = idsToRecreate.map((item) => item.id);
const itemsToCreate = [...idsToCreate, ...idsToRecreate];
const foldersToCreate = itemsToCreate.filter(isNavigationMenuItemFolder);
const nonFoldersToCreate = itemsToCreate.filter(
(item) => !isNavigationMenuItemFolder(item),
await deleteManyNavigationMenuItems(recreateIds);
const allItemsToCreate = [...itemsToCreate, ...idsToRecreate];
const resolveFolderId = (draftFolderId: string): string => draftFolderId;
const createInputs = allItemsToCreate.map((draftItem) =>
buildCreateNavigationMenuItemInput(draftItem, resolveFolderId),
);
const createdFolderIdByDraftId = new Map<string, string>();
const resolveFolderId = (draftFolderId: string): string =>
createdFolderIdByDraftId.get(draftFolderId) ?? draftFolderId;
await createManyNavigationMenuItems(createInputs);
const orderedFolders = orderFoldersForCreation(foldersToCreate, currentIds);
for (const draftItem of orderedFolders) {
const input = buildCreateNavigationMenuItemInput(
draftItem,
resolveFolderId,
);
const result = await createNavigationMenuItemMutation({
variables: { input },
});
const created = result.data?.createNavigationMenuItem;
if (isDefined(created?.id)) {
createdFolderIdByDraftId.set(draftItem.id, created.id);
}
}
const idsToRecreateSet = new Set(recreateIds);
for (const draftItem of nonFoldersToCreate) {
const input = buildCreateNavigationMenuItemInput(
draftItem,
resolveFolderId,
);
await createNavigationMenuItemMutation({
variables: { input },
});
}
const updateInputs = buildUpdateInputsFromDraft({
draft,
workspaceItemsById,
idsToRecreateSet,
resolveFolderId,
});
const idsToRecreateSet = new Set(idsToRecreate.map((i) => i.id));
for (const draftItem of draft) {
if (idsToRecreateSet.has(draftItem.id)) continue;
const original = workspaceItemsById.get(draftItem.id);
if (!original) continue;
const positionChanged = original.position !== draftItem.position;
const folderIdChanged =
(original.folderId ?? null) !== (draftItem.folderId ?? null);
const nameChanged =
(isNavigationMenuItemFolder(draftItem) ||
isNavigationMenuItemLink(draftItem)) &&
(original.name ?? null) !== (draftItem.name ?? null);
const linkChanged =
isNavigationMenuItemLink(draftItem) &&
(original.link ?? null) !== (draftItem.link ?? null);
const iconChanged =
isNavigationMenuItemFolder(draftItem) &&
(original.icon ?? null) !== (draftItem.icon ?? null);
const colorChanged =
isNavigationMenuItemFolder(draftItem) &&
(original.color ?? null) !== (draftItem.color ?? null);
if (
positionChanged ||
folderIdChanged ||
nameChanged ||
linkChanged ||
iconChanged ||
colorChanged
) {
const updateInput: {
id: string;
position?: number;
folderId?: string | null;
name?: string;
link?: string | null;
icon?: string | null;
color?: string | null;
} = { id: draftItem.id };
if (positionChanged) {
updateInput.position = draftItem.position;
}
if (folderIdChanged) {
updateInput.folderId =
draftItem.folderId != null
? resolveFolderId(draftItem.folderId)
: null;
}
if (nameChanged && isNavigationMenuItemFolder(draftItem)) {
updateInput.name = draftItem.name ?? undefined;
}
if (nameChanged && isNavigationMenuItemLink(draftItem)) {
updateInput.name = draftItem.name ?? undefined;
}
if (linkChanged && isNavigationMenuItemLink(draftItem)) {
const linkUrl = (draftItem.link ?? '').trim();
updateInput.link = linkUrl
? linkUrl.startsWith('http://') || linkUrl.startsWith('https://')
? linkUrl
: `https://${linkUrl}`
: null;
}
if (iconChanged && isNavigationMenuItemFolder(draftItem)) {
updateInput.icon = draftItem.icon ?? null;
}
if (colorChanged) {
updateInput.color = draftItem.color ?? null;
}
await updateNavigationMenuItem(updateInput);
}
}
await updateManyNavigationMenuItems(updateInputs);
}, [
updateNavigationMenuItem,
deleteNavigationMenuItem,
createNavigationMenuItemMutation,
createManyNavigationMenuItems,
deleteManyNavigationMenuItems,
updateManyNavigationMenuItems,
updateOneObjectMetadataItem,
store,
]);
@@ -0,0 +1,95 @@
import { ensureAbsoluteUrl, isDefined } from 'twenty-shared/utils';
import {
type NavigationMenuItem,
type UpdateOneNavigationMenuItemInput,
} from '~/generated-metadata/graphql';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/isNavigationMenuItemFolder';
import { isNavigationMenuItemLink } from '@/navigation-menu-item/common/utils/isNavigationMenuItemLink';
export const buildUpdateInputsFromDraft = ({
draft,
workspaceItemsById,
idsToRecreateSet,
resolveFolderId,
}: {
draft: NavigationMenuItem[];
workspaceItemsById: Map<string, NavigationMenuItem>;
idsToRecreateSet: Set<string>;
resolveFolderId: (draftFolderId: string) => string;
}): UpdateOneNavigationMenuItemInput[] => {
const updateInputs = [];
for (const draftItem of draft) {
if (idsToRecreateSet.has(draftItem.id)) {
continue;
}
const original = workspaceItemsById.get(draftItem.id);
if (!original) {
continue;
}
const positionChanged = original.position !== draftItem.position;
const folderIdChanged =
(original.folderId ?? null) !== (draftItem.folderId ?? null);
const nameChanged =
(isNavigationMenuItemFolder(draftItem) ||
isNavigationMenuItemLink(draftItem)) &&
(original.name ?? null) !== (draftItem.name ?? null);
const linkChanged =
isNavigationMenuItemLink(draftItem) &&
(original.link ?? null) !== (draftItem.link ?? null);
const iconChanged =
isNavigationMenuItemFolder(draftItem) &&
(original.icon ?? null) !== (draftItem.icon ?? null);
const colorChanged =
isNavigationMenuItemFolder(draftItem) &&
(original.color ?? null) !== (draftItem.color ?? null);
if (
!positionChanged &&
!folderIdChanged &&
!nameChanged &&
!linkChanged &&
!iconChanged &&
!colorChanged
) {
continue;
}
const updatePayload: UpdateOneNavigationMenuItemInput['update'] = {};
if (positionChanged) {
updatePayload.position = draftItem.position;
}
if (folderIdChanged) {
updatePayload.folderId = isDefined(draftItem.folderId)
? resolveFolderId(draftItem.folderId)
: null;
}
if (nameChanged && isNavigationMenuItemFolder(draftItem)) {
updatePayload.name = draftItem.name;
}
if (nameChanged && isNavigationMenuItemLink(draftItem)) {
updatePayload.name = draftItem.name;
}
if (linkChanged && isNavigationMenuItemLink(draftItem)) {
const linkUrl = (draftItem.link ?? '').trim();
updatePayload.link = linkUrl ? ensureAbsoluteUrl(linkUrl) : null;
}
if (iconChanged && isNavigationMenuItemFolder(draftItem)) {
updatePayload.icon = draftItem.icon ?? null;
}
if (colorChanged) {
updatePayload.color = draftItem.color ?? null;
}
updateInputs.push({
id: draftItem.id,
update: updatePayload,
});
}
return updateInputs;
};
@@ -0,0 +1,52 @@
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
type ObjectMetadataColorUpdate = {
idToUpdate: string;
color: string;
};
export const getObjectMetadataColorUpdates = ({
draft,
objectMetadataItems,
}: {
draft: NavigationMenuItem[];
objectMetadataItems: EnrichedObjectMetadataItem[];
}): ObjectMetadataColorUpdate[] => {
const updates: ObjectMetadataColorUpdate[] = [];
for (const draftItem of draft) {
if (draftItem.type !== NavigationMenuItemType.OBJECT) {
continue;
}
if (!isDefined(draftItem.targetObjectMetadataId)) {
continue;
}
if (!isDefined(draftItem.color)) {
continue;
}
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === draftItem.targetObjectMetadataId,
);
if (!isDefined(objectMetadataItem)) {
continue;
}
if (objectMetadataItem.color === draftItem.color) {
continue;
}
updates.push({
idToUpdate: draftItem.targetObjectMetadataId,
color: draftItem.color,
});
}
return updates;
};
@@ -1,6 +1,5 @@
import { useCreateNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useCreateNavigationMenuItem';
import { useCreateManyNavigationMenuItems } from '@/navigation-menu-item/common/hooks/useCreateManyNavigationMenuItems';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
@@ -11,6 +10,9 @@ import { type View } from '@/views/types/View';
import { useDestroyViewFromCurrentState } from '@/views/view-picker/hooks/useDestroyViewFromCurrentState';
import { viewPickerReferenceViewIdComponentState } from '@/views/view-picker/states/viewPickerReferenceViewIdComponentState';
import { useLingui } from '@lingui/react/macro';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import {
IconHeart,
IconLock,
@@ -53,7 +55,7 @@ export const ViewPickerOptionDropdown = ({
);
const hasViewsPermission = useHasPermissionFlag(PermissionFlagType.VIEWS);
const { createNavigationMenuItem } = useCreateNavigationMenuItem();
const { createManyNavigationMenuItems } = useCreateManyNavigationMenuItems();
const { navigationMenuItems, currentWorkspaceMemberId } =
useNavigationMenuItemsData();
@@ -76,7 +78,24 @@ export const ViewPickerOptionDropdown = ({
const handleAddToFavorites = () => {
if (!isFavorite) {
createNavigationMenuItem(view as unknown as ObjectRecord, 'view');
const relevantItems = navigationMenuItems.filter(
(item) => !isDefined(item.folderId) && isDefined(item.userWorkspaceId),
);
const maxPosition = Math.max(
...relevantItems.map((item) => item.position),
0,
);
createManyNavigationMenuItems([
{
id: uuidv4(),
type: NavigationMenuItemType.VIEW,
viewId: view.id,
userWorkspaceId: currentWorkspaceMemberId,
position: maxPosition + 1,
},
]);
}
closeDropdown(dropdownId);
};
@@ -65,6 +65,26 @@ export class NavigationMenuItemResolver {
});
}
@Mutation(() => [NavigationMenuItemDTO])
@UseGuards(NoPermissionGuard)
async createManyNavigationMenuItems(
@Args('inputs', { type: () => [CreateNavigationMenuItemInput] })
inputs: CreateNavigationMenuItemInput[],
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
@Context() context: { req: { application?: ApplicationEntity } },
): Promise<NavigationMenuItemDTO[]> {
return await this.navigationMenuItemService.createMany({
inputs,
workspaceId: workspace.id,
authUserWorkspaceId: userWorkspaceId,
authApiKeyId: apiKey?.id,
authApplicationId: context.req.application?.id,
});
}
@Mutation(() => NavigationMenuItemDTO)
@UseGuards(NoPermissionGuard)
async createNavigationMenuItem(
@@ -84,6 +104,26 @@ export class NavigationMenuItemResolver {
});
}
@Mutation(() => [NavigationMenuItemDTO])
@UseGuards(NoPermissionGuard)
async updateManyNavigationMenuItems(
@Args('inputs', { type: () => [UpdateOneNavigationMenuItemInput] })
inputs: UpdateOneNavigationMenuItemInput[],
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
@Context() context: { req: { application?: ApplicationEntity } },
): Promise<NavigationMenuItemDTO[]> {
return await this.navigationMenuItemService.updateMany({
inputs,
workspaceId: workspace.id,
authUserWorkspaceId: userWorkspaceId,
authApiKeyId: apiKey?.id,
authApplicationId: context.req.application?.id,
});
}
@Mutation(() => NavigationMenuItemDTO)
@UseGuards(NoPermissionGuard)
async updateNavigationMenuItem(
@@ -103,6 +143,25 @@ export class NavigationMenuItemResolver {
});
}
@Mutation(() => [NavigationMenuItemDTO])
@UseGuards(NoPermissionGuard)
async deleteManyNavigationMenuItems(
@Args('ids', { type: () => [UUIDScalarType] }) ids: string[],
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
@Context() context: { req: { application?: ApplicationEntity } },
): Promise<NavigationMenuItemDTO[]> {
return await this.navigationMenuItemService.deleteMany({
ids,
workspaceId: workspace.id,
authUserWorkspaceId: userWorkspaceId,
authApiKeyId: apiKey?.id,
authApplicationId: context.req.application?.id,
});
}
@Mutation(() => NavigationMenuItemDTO)
@UseGuards(NoPermissionGuard)
async deleteNavigationMenuItem(
@@ -7,6 +7,8 @@ import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/wo
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
import { addFlatNavigationMenuItemToMapsAndUpdateIndex } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/add-flat-navigation-menu-item-to-maps-and-update-index.util';
import { fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/from-create-navigation-menu-item-input-to-flat-navigation-menu-item-to-create.util';
import { fromDeleteNavigationMenuItemInputToFlatNavigationMenuItemOrThrow } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/from-delete-navigation-menu-item-input-to-flat-navigation-menu-item-or-throw.util';
import { fromFlatNavigationMenuItemToNavigationMenuItemDto } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/from-flat-navigation-menu-item-to-navigation-menu-item-dto.util';
@@ -14,7 +16,10 @@ import { fromUpdateNavigationMenuItemInputToFlatNavigationMenuItemToUpdateOrThro
import { type CreateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input';
import { type NavigationMenuItemDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto';
import { RecordIdentifierDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/record-identifier.dto';
import { type UpdateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input';
import {
type UpdateNavigationMenuItemInput,
type UpdateOneNavigationMenuItemInput,
} from 'src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input';
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
import {
NavigationMenuItemException,
@@ -123,13 +128,55 @@ export class NavigationMenuItemService {
authApiKeyId?: string;
authApplicationId?: string;
}): Promise<NavigationMenuItemDTO> {
await this.navigationMenuItemAccessService.canUserCreateNavigationMenuItem({
userWorkspaceId: authUserWorkspaceId,
const createdItems = await this.createMany({
inputs: [input],
workspaceId,
apiKeyId: authApiKeyId,
applicationId: authApplicationId,
inputUserWorkspaceId: input.userWorkspaceId,
authUserWorkspaceId,
authApiKeyId,
authApplicationId,
});
const created = createdItems[0];
if (!isDefined(created)) {
throw new NavigationMenuItemException(
'Failed to create navigation menu item',
NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
);
}
return created;
}
async createMany({
inputs,
workspaceId,
authUserWorkspaceId,
authApiKeyId,
authApplicationId,
}: {
inputs: CreateNavigationMenuItemInput[];
workspaceId: string;
authUserWorkspaceId?: string;
authApiKeyId?: string;
authApplicationId?: string;
}): Promise<NavigationMenuItemDTO[]> {
if (inputs.length === 0) {
return [];
}
for (const input of inputs) {
await this.navigationMenuItemAccessService.canUserCreateNavigationMenuItem(
{
userWorkspaceId: authUserWorkspaceId,
workspaceId,
apiKeyId: authApiKeyId,
applicationId: authApplicationId,
inputUserWorkspaceId: input.userWorkspaceId,
},
);
}
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
@@ -150,30 +197,57 @@ export class NavigationMenuItemService {
},
);
const normalizedInput: CreateNavigationMenuItemInput = {
...input,
userWorkspaceId:
isDefined(input.userWorkspaceId) && isDefined(authUserWorkspaceId)
? authUserWorkspaceId
: input.userWorkspaceId,
};
const optimisticFlatNavigationMenuItemMaps = structuredClone(
existingFlatNavigationMenuItemMaps,
);
const flatNavigationMenuItemToCreate =
fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate({
createNavigationMenuItemInput: normalizedInput,
workspaceId,
flatApplication: workspaceCustomFlatApplication,
flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps,
flatObjectMetadataMaps,
flatViewMaps,
const foldersFirst = [...inputs].sort((a, b) => {
const aIsFolder = a.type === NavigationMenuItemType.FOLDER ? 0 : 1;
const bIsFolder = b.type === NavigationMenuItemType.FOLDER ? 0 : 1;
return aIsFolder - bIsFolder;
});
const flatEntityByInput = new Map<
CreateNavigationMenuItemInput,
FlatNavigationMenuItem
>();
const flatNavigationMenuItemsToCreate = foldersFirst.map((input) => {
const normalizedInput: CreateNavigationMenuItemInput = {
...input,
userWorkspaceId:
isDefined(input.userWorkspaceId) && isDefined(authUserWorkspaceId)
? authUserWorkspaceId
: input.userWorkspaceId,
};
const flatNavigationMenuItemToCreate =
fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate({
createNavigationMenuItemInput: normalizedInput,
workspaceId,
flatApplication: workspaceCustomFlatApplication,
flatNavigationMenuItemMaps: optimisticFlatNavigationMenuItemMaps,
flatObjectMetadataMaps,
flatViewMaps,
});
addFlatNavigationMenuItemToMapsAndUpdateIndex({
flatNavigationMenuItem: flatNavigationMenuItemToCreate,
flatNavigationMenuItemMaps: optimisticFlatNavigationMenuItemMaps,
});
flatEntityByInput.set(input, flatNavigationMenuItemToCreate);
return flatNavigationMenuItemToCreate;
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
navigationMenuItem: {
flatEntityToCreate: [flatNavigationMenuItemToCreate],
flatEntityToCreate: flatNavigationMenuItemsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
@@ -188,7 +262,7 @@ export class NavigationMenuItemService {
if (validateAndBuildResult.status === 'fail') {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while creating navigation menu item',
'Multiple validation errors occurred while creating navigation menu items',
);
}
@@ -200,11 +274,13 @@ export class NavigationMenuItemService {
},
);
return fromFlatNavigationMenuItemToNavigationMenuItemDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: flatNavigationMenuItemToCreate.id,
flatEntityMaps: recomputedFlatNavigationMenuItemMaps,
}),
return inputs.map((input) =>
fromFlatNavigationMenuItemToNavigationMenuItemDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: flatEntityByInput.get(input)!.id,
flatEntityMaps: recomputedFlatNavigationMenuItemMaps,
}),
),
);
}
@@ -221,6 +297,45 @@ export class NavigationMenuItemService {
authApiKeyId?: string;
authApplicationId?: string;
}): Promise<NavigationMenuItemDTO> {
const { id, ...update } = input;
const updatedItems = await this.updateMany({
inputs: [{ id, update }],
workspaceId,
authUserWorkspaceId,
authApiKeyId,
authApplicationId,
});
const updated = updatedItems[0];
if (!isDefined(updated)) {
throw new NavigationMenuItemException(
'Failed to update navigation menu item',
NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
);
}
return updated;
}
async updateMany({
inputs,
workspaceId,
authUserWorkspaceId,
authApiKeyId,
authApplicationId,
}: {
inputs: UpdateOneNavigationMenuItemInput[];
workspaceId: string;
authUserWorkspaceId?: string;
authApiKeyId?: string;
authApplicationId?: string;
}): Promise<NavigationMenuItemDTO[]> {
if (inputs.length === 0) {
return [];
}
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
@@ -234,29 +349,41 @@ export class NavigationMenuItemService {
},
);
const existingNavigationMenuItem = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: input.id,
flatEntityMaps: existingFlatNavigationMenuItemMaps,
});
const flatNavigationMenuItemsToUpdate: FlatNavigationMenuItem[] = [];
if (isDefined(existingNavigationMenuItem)) {
await this.navigationMenuItemAccessService.canUserUpdateNavigationMenuItem(
{
userWorkspaceId: authUserWorkspaceId,
workspaceId,
apiKeyId: authApiKeyId,
applicationId: authApplicationId,
existingUserWorkspaceId: existingNavigationMenuItem.userWorkspaceId,
},
for (const { id, update } of inputs) {
const updateInput: UpdateNavigationMenuItemInput & { id: string } = {
...update,
id,
};
const existingNavigationMenuItem = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: id,
flatEntityMaps: existingFlatNavigationMenuItemMaps,
});
if (isDefined(existingNavigationMenuItem)) {
await this.navigationMenuItemAccessService.canUserUpdateNavigationMenuItem(
{
userWorkspaceId: authUserWorkspaceId,
workspaceId,
apiKeyId: authApiKeyId,
applicationId: authApplicationId,
existingUserWorkspaceId: existingNavigationMenuItem.userWorkspaceId,
},
);
}
flatNavigationMenuItemsToUpdate.push(
fromUpdateNavigationMenuItemInputToFlatNavigationMenuItemToUpdateOrThrow(
{
flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps,
updateNavigationMenuItemInput: updateInput,
},
),
);
}
const flatNavigationMenuItemToUpdate =
fromUpdateNavigationMenuItemInputToFlatNavigationMenuItemToUpdateOrThrow({
flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps,
updateNavigationMenuItemInput: input,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
@@ -264,7 +391,7 @@ export class NavigationMenuItemService {
navigationMenuItem: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [flatNavigationMenuItemToUpdate],
flatEntityToUpdate: flatNavigationMenuItemsToUpdate,
},
},
workspaceId,
@@ -277,7 +404,7 @@ export class NavigationMenuItemService {
if (validateAndBuildResult.status === 'fail') {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while updating navigation menu item',
'Multiple validation errors occurred while updating navigation menu items',
);
}
@@ -289,11 +416,13 @@ export class NavigationMenuItemService {
},
);
return fromFlatNavigationMenuItemToNavigationMenuItemDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: input.id,
flatEntityMaps: recomputedFlatNavigationMenuItemMaps,
}),
return inputs.map(({ id }) =>
fromFlatNavigationMenuItemToNavigationMenuItemDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedFlatNavigationMenuItemMaps,
}),
),
);
}
@@ -310,6 +439,53 @@ export class NavigationMenuItemService {
authApiKeyId?: string;
authApplicationId?: string;
}): Promise<NavigationMenuItemDTO> {
const deletedItems = await this.deleteMany({
ids: [id],
workspaceId,
authUserWorkspaceId,
authApiKeyId,
authApplicationId,
});
const deleted = deletedItems[0];
if (!isDefined(deleted)) {
throw new NavigationMenuItemException(
'Failed to delete navigation menu item',
NavigationMenuItemExceptionCode.NAVIGATION_MENU_ITEM_NOT_FOUND,
);
}
return deleted;
}
async deleteMany({
ids,
workspaceId,
authUserWorkspaceId,
authApiKeyId,
authApplicationId,
}: {
ids: string[];
workspaceId: string;
authUserWorkspaceId?: string;
authApiKeyId?: string;
authApplicationId?: string;
}): Promise<NavigationMenuItemDTO[]> {
if (ids.length === 0) {
return [];
}
const uniqueOrderedIds: string[] = [];
const seenId = new Set<string>();
for (const id of ids) {
if (!seenId.has(id)) {
seenId.add(id);
uniqueOrderedIds.push(id);
}
}
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
@@ -323,30 +499,55 @@ export class NavigationMenuItemService {
},
);
const flatNavigationMenuItemToDelete =
fromDeleteNavigationMenuItemInputToFlatNavigationMenuItemOrThrow({
flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps,
navigationMenuItemId: id,
});
const flatEntitiesToDeleteOrdered: FlatNavigationMenuItem[] = [];
const seenDeleteId = new Set<string>();
const deletedNavigationMenuItemDtoByRequestedId = new Map<
string,
NavigationMenuItemDTO
>();
await this.navigationMenuItemAccessService.canUserDeleteNavigationMenuItem({
userWorkspaceId: authUserWorkspaceId,
workspaceId,
apiKeyId: authApiKeyId,
applicationId: authApplicationId,
existingUserWorkspaceId: flatNavigationMenuItemToDelete.userWorkspaceId,
});
for (const requestedId of uniqueOrderedIds) {
const flatNavigationMenuItemRoot =
fromDeleteNavigationMenuItemInputToFlatNavigationMenuItemOrThrow({
flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps,
navigationMenuItemId: requestedId,
});
const flatEntitiesToDelete = [flatNavigationMenuItemToDelete];
await this.navigationMenuItemAccessService.canUserDeleteNavigationMenuItem(
{
userWorkspaceId: authUserWorkspaceId,
workspaceId,
apiKeyId: authApiKeyId,
applicationId: authApplicationId,
existingUserWorkspaceId: flatNavigationMenuItemRoot.userWorkspaceId,
},
);
if (flatNavigationMenuItemToDelete.type === NavigationMenuItemType.FOLDER) {
const userWorkspaceIdKey =
flatNavigationMenuItemToDelete.userWorkspaceId ?? 'null';
const folderChildren =
existingFlatNavigationMenuItemMaps.byUserWorkspaceIdAndFolderId[
userWorkspaceIdKey
]?.[id] ?? [];
flatEntitiesToDelete.unshift(...folderChildren);
deletedNavigationMenuItemDtoByRequestedId.set(
requestedId,
fromFlatNavigationMenuItemToNavigationMenuItemDto(
flatNavigationMenuItemRoot,
),
);
const flatEntitiesForRoot = [flatNavigationMenuItemRoot];
if (flatNavigationMenuItemRoot.type === NavigationMenuItemType.FOLDER) {
const userWorkspaceIdKey =
flatNavigationMenuItemRoot.userWorkspaceId ?? 'null';
const folderChildren =
existingFlatNavigationMenuItemMaps.byUserWorkspaceIdAndFolderId[
userWorkspaceIdKey
]?.[requestedId] ?? [];
flatEntitiesForRoot.unshift(...folderChildren);
}
for (const flatEntity of flatEntitiesForRoot) {
if (!seenDeleteId.has(flatEntity.id)) {
seenDeleteId.add(flatEntity.id);
flatEntitiesToDeleteOrdered.push(flatEntity);
}
}
}
const validateAndBuildResult =
@@ -355,7 +556,7 @@ export class NavigationMenuItemService {
allFlatEntityOperationByMetadataName: {
navigationMenuItem: {
flatEntityToCreate: [],
flatEntityToDelete: flatEntitiesToDelete,
flatEntityToDelete: flatEntitiesToDeleteOrdered,
flatEntityToUpdate: [],
},
},
@@ -369,13 +570,22 @@ export class NavigationMenuItemService {
if (validateAndBuildResult.status === 'fail') {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while deleting navigation menu item',
'Multiple validation errors occurred while deleting navigation menu items',
);
}
return fromFlatNavigationMenuItemToNavigationMenuItemDto(
flatNavigationMenuItemToDelete,
);
return uniqueOrderedIds.map((requestedId) => {
const dto = deletedNavigationMenuItemDtoByRequestedId.get(requestedId);
if (!isDefined(dto)) {
throw new NavigationMenuItemException(
'Failed to resolve deleted navigation menu item in batch',
NavigationMenuItemExceptionCode.NAVIGATION_MENU_ITEM_NOT_FOUND,
);
}
return dto;
});
}
async findTargetRecord({
@@ -0,0 +1,59 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`NavigationMenuItem batch creation should fail when batch contains item with invalid folderId 1`] = `
{
"extensions": {
"code": "GRAPHQL_VALIDATION_FAILED",
"http": {
"status": 400,
},
"userFriendlyMessage": "An error occurred.",
"value": "not-a-valid-uuid",
},
"message": "Invalid UUID: 'not-a-valid-uuid'",
"name": "ValidationError",
}
`;
exports[`NavigationMenuItem batch creation should fail when batch contains item with invalid targetObjectMetadataId 1`] = `
{
"extensions": {
"code": "GRAPHQL_VALIDATION_FAILED",
"http": {
"status": 400,
},
"userFriendlyMessage": "An error occurred.",
"value": "not-a-valid-uuid",
},
"message": "Invalid UUID: 'not-a-valid-uuid'",
"name": "ValidationError",
}
`;
exports[`NavigationMenuItem batch creation should fail when batch contains item with invalid targetRecordId 1`] = `
{
"extensions": {
"code": "GRAPHQL_VALIDATION_FAILED",
"http": {
"status": 400,
},
"userFriendlyMessage": "An error occurred.",
"value": "not-a-valid-uuid",
},
"message": "Invalid UUID: 'not-a-valid-uuid'",
"name": "ValidationError",
}
`;
exports[`NavigationMenuItem batch creation should fail when batch contains item with missing targetRecordId 1`] = `
{
"extensions": {
"code": "NOT_FOUND",
"subCode": "RELATION_UNIVERSAL_IDENTIFIER_NOT_FOUND",
"userFriendlyMessage": "An error occurred.",
},
"message": "Could not find objectMetadata for given targetObjectMetadataId",
"name": "NotFoundError",
}
`;
@@ -0,0 +1,25 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`NavigationMenuItem batch deletion should fail when batch contains a non-existent id among valid ones 1`] = `
{
"extensions": {
"code": "NOT_FOUND",
"subCode": "NAVIGATION_MENU_ITEM_NOT_FOUND",
"userFriendlyMessage": "Navigation menu item not found.",
},
"message": "Navigation menu item not found",
"name": "NotFoundError",
}
`;
exports[`NavigationMenuItem batch deletion should fail when deleting with non-existent id 1`] = `
{
"extensions": {
"code": "NOT_FOUND",
"subCode": "NAVIGATION_MENU_ITEM_NOT_FOUND",
"userFriendlyMessage": "Navigation menu item not found.",
},
"message": "Navigation menu item not found",
"name": "NotFoundError",
}
`;
@@ -0,0 +1,40 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`NavigationMenuItem batch update should fail when batch contains an update for non-existent item 1`] = `
{
"extensions": {
"code": "NOT_FOUND",
"subCode": "NAVIGATION_MENU_ITEM_NOT_FOUND",
"userFriendlyMessage": "Navigation menu item not found.",
},
"message": "Navigation menu item not found",
"name": "NotFoundError",
}
`;
exports[`NavigationMenuItem batch update should fail when updating with invalid folderId 1`] = `
{
"extensions": {
"code": "GRAPHQL_VALIDATION_FAILED",
"http": {
"status": 400,
},
"userFriendlyMessage": "An error occurred.",
"value": "not-a-valid-uuid",
},
"message": "Invalid UUID: 'not-a-valid-uuid'",
"name": "ValidationError",
}
`;
exports[`NavigationMenuItem batch update should fail when updating with non-existent id 1`] = `
{
"extensions": {
"code": "NOT_FOUND",
"subCode": "NAVIGATION_MENU_ITEM_NOT_FOUND",
"userFriendlyMessage": "Navigation menu item not found.",
},
"message": "Navigation menu item not found",
"name": "NotFoundError",
}
`;
@@ -120,7 +120,7 @@ exports[`NavigationMenuItem creation should fail when creating with missing targ
},
"userFriendlyMessage": "Metadata validation failed",
},
"message": "Multiple validation errors occurred while creating navigation menu item",
"message": "Multiple validation errors occurred while creating navigation menu items",
"name": "GraphQLError",
}
`;
@@ -30,7 +30,7 @@ exports[`Navigation Menu Item update should fail with circular dependency when f
},
"userFriendlyMessage": "Metadata validation failed",
},
"message": "Multiple validation errors occurred while updating navigation menu item",
"message": "Multiple validation errors occurred while updating navigation menu items",
"name": "GraphQLError",
}
`;
@@ -65,7 +65,7 @@ exports[`Navigation Menu Item update should fail with circular dependency when u
},
"userFriendlyMessage": "Metadata validation failed",
},
"message": "Multiple validation errors occurred while updating navigation menu item",
"message": "Multiple validation errors occurred while updating navigation menu items",
"name": "GraphQLError",
}
`;
@@ -0,0 +1,81 @@
import { faker } from '@faker-js/faker';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { createManyNavigationMenuItems } from 'test/integration/metadata/suites/navigation-menu-item/utils/create-many-navigation-menu-items.util';
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { type CreateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input';
type TestContext = {
inputs: CreateNavigationMenuItemInput[];
};
const failingBatchCreationTestCases: EachTestingContext<TestContext>[] = [
{
title: 'when batch contains item with missing targetRecordId',
context: {
inputs: [
{
type: NavigationMenuItemType.RECORD,
targetObjectMetadataId: faker.string.uuid(),
} as CreateNavigationMenuItemInput,
],
},
},
{
title: 'when batch contains item with invalid targetRecordId',
context: {
inputs: [
{
type: NavigationMenuItemType.RECORD,
targetRecordId: 'not-a-valid-uuid',
targetObjectMetadataId: faker.string.uuid(),
},
],
},
},
{
title: 'when batch contains item with invalid targetObjectMetadataId',
context: {
inputs: [
{
type: NavigationMenuItemType.RECORD,
targetRecordId: faker.string.uuid(),
targetObjectMetadataId: 'not-a-valid-uuid',
},
],
},
},
{
title: 'when batch contains item with invalid folderId',
context: {
inputs: [
{
type: NavigationMenuItemType.RECORD,
targetRecordId: faker.string.uuid(),
targetObjectMetadataId: faker.string.uuid(),
folderId: 'not-a-valid-uuid',
},
],
},
},
];
describe('NavigationMenuItem batch creation should fail', () => {
it.each(eachTestingContextFilter(failingBatchCreationTestCases))(
'$title',
async ({ context }) => {
const { errors } = await createManyNavigationMenuItems({
expectToFail: true,
inputs: context.inputs,
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
},
);
});
@@ -0,0 +1,100 @@
import { faker } from '@faker-js/faker';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { createNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/create-navigation-menu-item.util';
import { deleteNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/delete-navigation-menu-item.util';
import { deleteManyNavigationMenuItems } from 'test/integration/metadata/suites/navigation-menu-item/utils/delete-many-navigation-menu-items.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
type TestContext = {
buildIds: (realItemId: string) => string[];
};
const failingBatchDeletionTestCases: EachTestingContext<TestContext>[] = [
{
title: 'when deleting with non-existent id',
context: {
buildIds: () => [faker.string.uuid()],
},
},
{
title: 'when batch contains a non-existent id among valid ones',
context: {
buildIds: (realItemId: string) => [realItemId, faker.string.uuid()],
},
},
];
describe('NavigationMenuItem batch deletion should fail', () => {
let createdNavigationMenuItemId: string;
let personObjectMetadataId: string;
beforeAll(async () => {
const { objects } = await findManyObjectMetadata({
expectToFail: false,
input: {
filter: {},
paging: { first: 1000 },
},
gqlFields: `
id
nameSingular
`,
});
jestExpectToBeDefined(objects);
const personObjectMetadata = objects.find(
(object: { nameSingular: string }) => object.nameSingular === 'person',
);
jestExpectToBeDefined(personObjectMetadata);
personObjectMetadataId = personObjectMetadata.id;
});
beforeEach(async () => {
const { data } = await createNavigationMenuItem({
expectToFail: false,
input: {
type: NavigationMenuItemType.RECORD,
targetRecordId: faker.string.uuid(),
targetObjectMetadataId: personObjectMetadataId,
position: 1,
},
});
createdNavigationMenuItemId = data.createNavigationMenuItem.id;
});
afterEach(async () => {
if (createdNavigationMenuItemId) {
await deleteNavigationMenuItem({
expectToFail: false,
input: { id: createdNavigationMenuItemId },
});
createdNavigationMenuItemId = undefined as unknown as string;
}
});
it.each(eachTestingContextFilter(failingBatchDeletionTestCases))(
'$title',
async ({ context }) => {
const ids = context.buildIds(createdNavigationMenuItemId);
const { errors } = await deleteManyNavigationMenuItems({
expectToFail: true,
ids,
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
},
);
});
@@ -0,0 +1,127 @@
import { faker } from '@faker-js/faker';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { createNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/create-navigation-menu-item.util';
import { deleteNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/delete-navigation-menu-item.util';
import { updateManyNavigationMenuItems } from 'test/integration/metadata/suites/navigation-menu-item/utils/update-many-navigation-menu-items.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import {
eachTestingContextFilter,
type EachTestingContext,
} from 'twenty-shared/testing';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import { type UpdateOneNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input';
type TestContext = {
buildInputs: (itemId: string) => UpdateOneNavigationMenuItemInput[];
};
const failingBatchUpdateTestCases: EachTestingContext<TestContext>[] = [
{
title: 'when updating with non-existent id',
context: {
buildInputs: () => [
{
id: faker.string.uuid(),
update: { position: 5 },
},
],
},
},
{
title: 'when updating with invalid folderId',
context: {
buildInputs: (itemId: string) => [
{
id: itemId,
update: { folderId: 'not-a-valid-uuid' },
},
],
},
},
{
title: 'when batch contains an update for non-existent item',
context: {
buildInputs: (itemId: string) => [
{
id: itemId,
update: { position: 10 },
},
{
id: faker.string.uuid(),
update: { position: 20 },
},
],
},
},
];
describe('NavigationMenuItem batch update should fail', () => {
let createdNavigationMenuItemId: string;
let personObjectMetadataId: string;
beforeAll(async () => {
const { objects } = await findManyObjectMetadata({
expectToFail: false,
input: {
filter: {},
paging: { first: 1000 },
},
gqlFields: `
id
nameSingular
`,
});
jestExpectToBeDefined(objects);
const personObjectMetadata = objects.find(
(object: { nameSingular: string }) => object.nameSingular === 'person',
);
jestExpectToBeDefined(personObjectMetadata);
personObjectMetadataId = personObjectMetadata.id;
});
beforeEach(async () => {
const { data } = await createNavigationMenuItem({
expectToFail: false,
input: {
type: NavigationMenuItemType.RECORD,
targetRecordId: faker.string.uuid(),
targetObjectMetadataId: personObjectMetadataId,
position: 1,
},
});
createdNavigationMenuItemId = data.createNavigationMenuItem.id;
});
afterEach(async () => {
if (createdNavigationMenuItemId) {
await deleteNavigationMenuItem({
expectToFail: false,
input: { id: createdNavigationMenuItemId },
});
createdNavigationMenuItemId = undefined as unknown as string;
}
});
it.each(eachTestingContextFilter(failingBatchUpdateTestCases))(
'$title',
async ({ context }) => {
const inputs = context.buildInputs(createdNavigationMenuItemId);
const { errors } = await updateManyNavigationMenuItems({
expectToFail: true,
inputs,
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
},
);
});
@@ -0,0 +1,218 @@
import { faker } from '@faker-js/faker';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { createManyNavigationMenuItems } from 'test/integration/metadata/suites/navigation-menu-item/utils/create-many-navigation-menu-items.util';
import { deleteManyNavigationMenuItems } from 'test/integration/metadata/suites/navigation-menu-item/utils/delete-many-navigation-menu-items.util';
import { findNavigationMenuItems } from 'test/integration/metadata/suites/navigation-menu-item/utils/find-navigation-menu-items.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
describe('NavigationMenuItem batch creation should succeed', () => {
let personObjectMetadataId: string;
let companyObjectMetadataId: string;
let createdIds: string[] = [];
beforeAll(async () => {
const { objects } = await findManyObjectMetadata({
expectToFail: false,
input: {
filter: {},
paging: { first: 1000 },
},
gqlFields: `
id
nameSingular
`,
});
jestExpectToBeDefined(objects);
const personObjectMetadata = objects.find(
(object: { nameSingular: string }) => object.nameSingular === 'person',
);
const companyObjectMetadata = objects.find(
(object: { nameSingular: string }) => object.nameSingular === 'company',
);
jestExpectToBeDefined(personObjectMetadata);
jestExpectToBeDefined(companyObjectMetadata);
personObjectMetadataId = personObjectMetadata.id;
companyObjectMetadataId = companyObjectMetadata.id;
});
afterEach(async () => {
if (createdIds.length > 0) {
await deleteManyNavigationMenuItems({
ids: createdIds,
expectToFail: false,
});
createdIds = [];
}
});
it('should create multiple navigation menu items in a single batch', async () => {
const targetRecordId1 = faker.string.uuid();
const targetRecordId2 = faker.string.uuid();
const { data } = await createManyNavigationMenuItems({
expectToFail: false,
inputs: [
{
type: NavigationMenuItemType.RECORD,
targetRecordId: targetRecordId1,
targetObjectMetadataId: personObjectMetadataId,
},
{
type: NavigationMenuItemType.RECORD,
targetRecordId: targetRecordId2,
targetObjectMetadataId: companyObjectMetadataId,
},
],
});
jestExpectToBeDefined(data?.createManyNavigationMenuItems);
const items = data.createManyNavigationMenuItems;
createdIds = items.map((item) => item.id);
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
id: expect.any(String),
targetRecordId: targetRecordId1,
targetObjectMetadataId: personObjectMetadataId,
});
expect(items[1]).toMatchObject({
id: expect.any(String),
targetRecordId: targetRecordId2,
targetObjectMetadataId: companyObjectMetadataId,
});
});
it('should create a folder and a child item in a single batch', async () => {
const folderId = faker.string.uuid();
const targetRecordId = faker.string.uuid();
const { data } = await createManyNavigationMenuItems({
expectToFail: false,
inputs: [
{
id: folderId,
type: NavigationMenuItemType.FOLDER,
name: 'Test Folder',
position: 1,
},
{
type: NavigationMenuItemType.RECORD,
targetRecordId,
targetObjectMetadataId: personObjectMetadataId,
folderId,
position: 1,
},
],
});
jestExpectToBeDefined(data?.createManyNavigationMenuItems);
const items = data.createManyNavigationMenuItems;
createdIds = items.map((item) => item.id);
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({
id: folderId,
type: NavigationMenuItemType.FOLDER,
name: 'Test Folder',
});
expect(items[1]).toMatchObject({
targetRecordId,
folderId,
});
});
it('should preserve input order in the response', async () => {
const targetRecordId1 = faker.string.uuid();
const targetRecordId2 = faker.string.uuid();
const targetRecordId3 = faker.string.uuid();
const { data } = await createManyNavigationMenuItems({
expectToFail: false,
inputs: [
{
type: NavigationMenuItemType.RECORD,
targetRecordId: targetRecordId1,
targetObjectMetadataId: personObjectMetadataId,
position: 3,
},
{
type: NavigationMenuItemType.RECORD,
targetRecordId: targetRecordId2,
targetObjectMetadataId: personObjectMetadataId,
position: 1,
},
{
type: NavigationMenuItemType.RECORD,
targetRecordId: targetRecordId3,
targetObjectMetadataId: personObjectMetadataId,
position: 2,
},
],
});
jestExpectToBeDefined(data?.createManyNavigationMenuItems);
const items = data.createManyNavigationMenuItems;
createdIds = items.map((item) => item.id);
expect(items).toHaveLength(3);
expect(items[0].targetRecordId).toBe(targetRecordId1);
expect(items[1].targetRecordId).toBe(targetRecordId2);
expect(items[2].targetRecordId).toBe(targetRecordId3);
});
it('should return empty array for empty inputs', async () => {
const { data } = await createManyNavigationMenuItems({
expectToFail: false,
inputs: [],
});
jestExpectToBeDefined(data?.createManyNavigationMenuItems);
expect(data.createManyNavigationMenuItems).toHaveLength(0);
});
it('should create items visible in findAll', async () => {
const targetRecordId = faker.string.uuid();
const { data: createData } = await createManyNavigationMenuItems({
expectToFail: false,
inputs: [
{
type: NavigationMenuItemType.RECORD,
targetRecordId,
targetObjectMetadataId: personObjectMetadataId,
},
],
});
jestExpectToBeDefined(createData?.createManyNavigationMenuItems);
createdIds = createData.createManyNavigationMenuItems.map(
(item) => item.id,
);
const { data: findData } = await findNavigationMenuItems({
expectToFail: false,
input: undefined,
});
const foundItem = findData.navigationMenuItems.find(
(item) => item.id === createdIds[0],
);
expect(foundItem).toBeDefined();
});
});
@@ -0,0 +1,182 @@
import { faker } from '@faker-js/faker';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { createManyNavigationMenuItems } from 'test/integration/metadata/suites/navigation-menu-item/utils/create-many-navigation-menu-items.util';
import { deleteManyNavigationMenuItems } from 'test/integration/metadata/suites/navigation-menu-item/utils/delete-many-navigation-menu-items.util';
import { findNavigationMenuItems } from 'test/integration/metadata/suites/navigation-menu-item/utils/find-navigation-menu-items.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
describe('NavigationMenuItem batch deletion should succeed', () => {
let personObjectMetadataId: string;
let pendingCleanupIds: string[] = [];
afterEach(async () => {
if (pendingCleanupIds.length > 0) {
await deleteManyNavigationMenuItems({
ids: pendingCleanupIds,
expectToFail: false,
});
pendingCleanupIds = [];
}
});
beforeAll(async () => {
const { objects } = await findManyObjectMetadata({
expectToFail: false,
input: {
filter: {},
paging: { first: 1000 },
},
gqlFields: `
id
nameSingular
`,
});
jestExpectToBeDefined(objects);
const personObjectMetadata = objects.find(
(object: { nameSingular: string }) => object.nameSingular === 'person',
);
jestExpectToBeDefined(personObjectMetadata);
personObjectMetadataId = personObjectMetadata.id;
});
it('should delete multiple items in a single batch', async () => {
const { data: createData } = await createManyNavigationMenuItems({
expectToFail: false,
inputs: [
{
type: NavigationMenuItemType.RECORD,
targetRecordId: faker.string.uuid(),
targetObjectMetadataId: personObjectMetadataId,
},
{
type: NavigationMenuItemType.RECORD,
targetRecordId: faker.string.uuid(),
targetObjectMetadataId: personObjectMetadataId,
},
],
});
jestExpectToBeDefined(createData?.createManyNavigationMenuItems);
const items = createData.createManyNavigationMenuItems;
const idsToDelete = items.map((item) => item.id);
pendingCleanupIds = [...idsToDelete];
const { data: deleteData } = await deleteManyNavigationMenuItems({
ids: idsToDelete,
expectToFail: false,
});
pendingCleanupIds = [];
jestExpectToBeDefined(deleteData?.deleteManyNavigationMenuItems);
expect(deleteData.deleteManyNavigationMenuItems).toHaveLength(2);
const { data: findData } = await findNavigationMenuItems({
expectToFail: false,
input: undefined,
});
for (const deletedId of idsToDelete) {
const foundItem = findData.navigationMenuItems.find(
(item) => item.id === deletedId,
);
expect(foundItem).toBeUndefined();
}
});
it('should delete a folder and cascade to its children', async () => {
const folderId = faker.string.uuid();
const { data: createData } = await createManyNavigationMenuItems({
expectToFail: false,
inputs: [
{
id: folderId,
type: NavigationMenuItemType.FOLDER,
name: 'Cascade Folder',
position: 1,
},
{
type: NavigationMenuItemType.RECORD,
targetRecordId: faker.string.uuid(),
targetObjectMetadataId: personObjectMetadataId,
folderId,
position: 1,
},
],
});
jestExpectToBeDefined(createData?.createManyNavigationMenuItems);
const items = createData.createManyNavigationMenuItems;
const childId = items[1].id;
pendingCleanupIds = items.map((item) => item.id);
const { data: deleteData } = await deleteManyNavigationMenuItems({
ids: [folderId],
expectToFail: false,
});
pendingCleanupIds = [];
jestExpectToBeDefined(deleteData?.deleteManyNavigationMenuItems);
expect(deleteData.deleteManyNavigationMenuItems).toHaveLength(1);
const { data: findData } = await findNavigationMenuItems({
expectToFail: false,
input: undefined,
});
expect(
findData.navigationMenuItems.find((item) => item.id === folderId),
).toBeUndefined();
expect(
findData.navigationMenuItems.find((item) => item.id === childId),
).toBeUndefined();
});
it('should handle deduplication of ids', async () => {
const { data: createData } = await createManyNavigationMenuItems({
expectToFail: false,
inputs: [
{
type: NavigationMenuItemType.RECORD,
targetRecordId: faker.string.uuid(),
targetObjectMetadataId: personObjectMetadataId,
},
],
});
jestExpectToBeDefined(createData?.createManyNavigationMenuItems);
const itemId = createData.createManyNavigationMenuItems[0].id;
pendingCleanupIds = [itemId];
const { data: deleteData } = await deleteManyNavigationMenuItems({
ids: [itemId, itemId],
expectToFail: false,
});
pendingCleanupIds = [];
jestExpectToBeDefined(deleteData?.deleteManyNavigationMenuItems);
expect(deleteData.deleteManyNavigationMenuItems).toHaveLength(1);
});
it('should return empty array for empty ids', async () => {
const { data } = await deleteManyNavigationMenuItems({
ids: [],
expectToFail: false,
});
jestExpectToBeDefined(data?.deleteManyNavigationMenuItems);
expect(data.deleteManyNavigationMenuItems).toHaveLength(0);
});
});
@@ -0,0 +1,150 @@
import { faker } from '@faker-js/faker';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { createManyNavigationMenuItems } from 'test/integration/metadata/suites/navigation-menu-item/utils/create-many-navigation-menu-items.util';
import { deleteManyNavigationMenuItems } from 'test/integration/metadata/suites/navigation-menu-item/utils/delete-many-navigation-menu-items.util';
import { updateManyNavigationMenuItems } from 'test/integration/metadata/suites/navigation-menu-item/utils/update-many-navigation-menu-items.util';
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
describe('NavigationMenuItem batch update should succeed', () => {
let personObjectMetadataId: string;
let createdIds: string[] = [];
beforeAll(async () => {
const { objects } = await findManyObjectMetadata({
expectToFail: false,
input: {
filter: {},
paging: { first: 1000 },
},
gqlFields: `
id
nameSingular
`,
});
jestExpectToBeDefined(objects);
const personObjectMetadata = objects.find(
(object: { nameSingular: string }) => object.nameSingular === 'person',
);
jestExpectToBeDefined(personObjectMetadata);
personObjectMetadataId = personObjectMetadata.id;
});
afterEach(async () => {
if (createdIds.length > 0) {
await deleteManyNavigationMenuItems({
ids: createdIds,
expectToFail: false,
});
createdIds = [];
}
});
it('should update positions of multiple items at once', async () => {
const { data: createData } = await createManyNavigationMenuItems({
expectToFail: false,
inputs: [
{
type: NavigationMenuItemType.RECORD,
targetRecordId: faker.string.uuid(),
targetObjectMetadataId: personObjectMetadataId,
position: 1,
},
{
type: NavigationMenuItemType.RECORD,
targetRecordId: faker.string.uuid(),
targetObjectMetadataId: personObjectMetadataId,
position: 2,
},
],
});
jestExpectToBeDefined(createData?.createManyNavigationMenuItems);
const items = createData.createManyNavigationMenuItems;
createdIds = items.map((item) => item.id);
const { data: updateData } = await updateManyNavigationMenuItems({
expectToFail: false,
inputs: [
{ id: items[0].id, update: { position: 10 } },
{ id: items[1].id, update: { position: 20 } },
],
});
jestExpectToBeDefined(updateData?.updateManyNavigationMenuItems);
const updatedItems = updateData.updateManyNavigationMenuItems;
expect(updatedItems).toHaveLength(2);
expect(updatedItems[0]).toMatchObject({
id: items[0].id,
position: 10,
});
expect(updatedItems[1]).toMatchObject({
id: items[1].id,
position: 20,
});
});
it('should update folderIds of multiple items at once', async () => {
const folderId = faker.string.uuid();
const { data: createData } = await createManyNavigationMenuItems({
expectToFail: false,
inputs: [
{
id: folderId,
type: NavigationMenuItemType.FOLDER,
name: 'Batch Folder',
position: 1,
},
{
type: NavigationMenuItemType.RECORD,
targetRecordId: faker.string.uuid(),
targetObjectMetadataId: personObjectMetadataId,
position: 1,
},
{
type: NavigationMenuItemType.RECORD,
targetRecordId: faker.string.uuid(),
targetObjectMetadataId: personObjectMetadataId,
position: 2,
},
],
});
jestExpectToBeDefined(createData?.createManyNavigationMenuItems);
const items = createData.createManyNavigationMenuItems;
createdIds = items.map((item) => item.id);
const { data: updateData } = await updateManyNavigationMenuItems({
expectToFail: false,
inputs: [
{ id: items[1].id, update: { folderId } },
{ id: items[2].id, update: { folderId } },
],
});
jestExpectToBeDefined(updateData?.updateManyNavigationMenuItems);
const updatedItems = updateData.updateManyNavigationMenuItems;
expect(updatedItems).toHaveLength(2);
expect(updatedItems[0]).toMatchObject({ id: items[1].id, folderId });
expect(updatedItems[1]).toMatchObject({ id: items[2].id, folderId });
});
it('should return empty array for empty inputs', async () => {
const { data } = await updateManyNavigationMenuItems({
expectToFail: false,
inputs: [],
});
jestExpectToBeDefined(data?.updateManyNavigationMenuItems);
expect(data.updateManyNavigationMenuItems).toHaveLength(0);
});
});
@@ -0,0 +1,42 @@
import gql from 'graphql-tag';
import { type CreateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input';
const DEFAULT_NAVIGATION_MENU_ITEM_GQL_FIELDS = `
id
type
userWorkspaceId
targetRecordId
targetObjectMetadataId
viewId
folderId
position
name
link
icon
color
applicationId
createdAt
updatedAt
`;
export const createManyNavigationMenuItemsQueryFactory = ({
inputs,
gqlFields = DEFAULT_NAVIGATION_MENU_ITEM_GQL_FIELDS,
}: {
inputs: CreateNavigationMenuItemInput[];
gqlFields?: string;
}) => ({
query: gql`
mutation CreateManyNavigationMenuItems(
$inputs: [CreateNavigationMenuItemInput!]!
) {
createManyNavigationMenuItems(inputs: $inputs) {
${gqlFields}
}
}
`,
variables: {
inputs,
},
});
@@ -0,0 +1,47 @@
import { createManyNavigationMenuItemsQueryFactory } from 'test/integration/metadata/suites/navigation-menu-item/utils/create-many-navigation-menu-items-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type CreateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input';
import { type NavigationMenuItemDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto';
export const createManyNavigationMenuItems = async ({
inputs,
gqlFields,
expectToFail = false,
token,
}: {
inputs: CreateNavigationMenuItemInput[];
gqlFields?: string;
expectToFail?: boolean;
token?: string;
}): CommonResponseBody<{
createManyNavigationMenuItems: NavigationMenuItemDTO[];
}> => {
const graphqlOperation = createManyNavigationMenuItemsQueryFactory({
inputs,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage:
'NavigationMenuItems batch creation should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage:
'NavigationMenuItems batch creation has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};
@@ -0,0 +1,28 @@
import gql from 'graphql-tag';
const DEFAULT_NAVIGATION_MENU_ITEM_GQL_FIELDS = `
id
type
targetRecordId
targetObjectMetadataId
position
`;
export const deleteManyNavigationMenuItemsQueryFactory = ({
ids,
gqlFields = DEFAULT_NAVIGATION_MENU_ITEM_GQL_FIELDS,
}: {
ids: string[];
gqlFields?: string;
}) => ({
query: gql`
mutation DeleteManyNavigationMenuItems($ids: [UUID!]!) {
deleteManyNavigationMenuItems(ids: $ids) {
${gqlFields}
}
}
`,
variables: {
ids,
},
});
@@ -0,0 +1,46 @@
import { deleteManyNavigationMenuItemsQueryFactory } from 'test/integration/metadata/suites/navigation-menu-item/utils/delete-many-navigation-menu-items-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type NavigationMenuItemDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto';
export const deleteManyNavigationMenuItems = async ({
ids,
gqlFields,
expectToFail = false,
token,
}: {
ids: string[];
gqlFields?: string;
expectToFail?: boolean;
token?: string;
}): CommonResponseBody<{
deleteManyNavigationMenuItems: NavigationMenuItemDTO[];
}> => {
const graphqlOperation = deleteManyNavigationMenuItemsQueryFactory({
ids,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage:
'NavigationMenuItems batch deletion should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage:
'NavigationMenuItems batch deletion has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};
@@ -0,0 +1,37 @@
import gql from 'graphql-tag';
import { type UpdateOneNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input';
const DEFAULT_NAVIGATION_MENU_ITEM_GQL_FIELDS = `
id
type
userWorkspaceId
targetRecordId
targetObjectMetadataId
folderId
position
applicationId
createdAt
updatedAt
`;
export const updateManyNavigationMenuItemsQueryFactory = ({
inputs,
gqlFields = DEFAULT_NAVIGATION_MENU_ITEM_GQL_FIELDS,
}: {
inputs: UpdateOneNavigationMenuItemInput[];
gqlFields?: string;
}) => ({
query: gql`
mutation UpdateManyNavigationMenuItems(
$inputs: [UpdateOneNavigationMenuItemInput!]!
) {
updateManyNavigationMenuItems(inputs: $inputs) {
${gqlFields}
}
}
`,
variables: {
inputs,
},
});
@@ -0,0 +1,47 @@
import { updateManyNavigationMenuItemsQueryFactory } from 'test/integration/metadata/suites/navigation-menu-item/utils/update-many-navigation-menu-items-query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
import { type NavigationMenuItemDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto';
import { type UpdateOneNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input';
export const updateManyNavigationMenuItems = async ({
inputs,
gqlFields,
expectToFail = false,
token,
}: {
inputs: UpdateOneNavigationMenuItemInput[];
gqlFields?: string;
expectToFail?: boolean;
token?: string;
}): CommonResponseBody<{
updateManyNavigationMenuItems: NavigationMenuItemDTO[];
}> => {
const graphqlOperation = updateManyNavigationMenuItemsQueryFactory({
inputs,
gqlFields,
});
const response = await makeMetadataAPIRequest(graphqlOperation, token);
if (expectToFail === true) {
warnIfNoErrorButExpectedToFail({
response,
errorMessage:
'NavigationMenuItems batch update should have failed but did not',
});
}
if (expectToFail === false) {
warnIfErrorButNotExpectedToFail({
response,
errorMessage:
'NavigationMenuItems batch update has failed but should not',
});
}
return { data: response.body.data, errors: response.body.errors };
};