feat: optimistic metadata store updates for navigation menu items (#18710)

## Summary

- **Optimistic metadata store updates**: Replace `refetchQueries` with
direct `addToDraft`/`applyChanges` calls in create, update, and delete
navigation menu item mutation hooks for instant UI feedback. Client-side
UUID generation enables optimistic creates before the server responds.
- **SSE event enrichment with `targetRecordIdentifier`**: Introduce
`NavigationMenuItemRecordIdentifierService` to resolve record display
info (label, image) and enrich SSE metadata events at emission time, so
the sidebar shows record names immediately without a page refresh.
- **Centralized role permission resolution**: Add
`resolveRolePermissionConfigFromAuthContext` to `PermissionsService`,
removing duplicated role resolution logic from individual services.
- **Mutation fragments include `targetRecordIdentifier`**: Switch
create/update/delete mutations from `NavigationMenuItemFields` to
`NavigationMenuItemQueryFields` so the mutation response includes
`targetRecordIdentifier`, preventing a brief gap where RECORD favorites
are invisible in the sidebar.
- **Folder UI fixes**: Remove transparent border on
`StyledFolderContainer` that caused a 1px size inconsistency between
folder and non-folder items in Favorites. Make the folder kebab menu
hover-only instead of always visible.
This commit is contained in:
Charles Bochet
2026-03-17 23:18:07 +01:00
committed by GitHub
parent 058414fae5
commit a82739cdb1
27 changed files with 1496 additions and 1416 deletions
File diff suppressed because one or more lines are too long
@@ -1,11 +1,11 @@
import { gql } from '@apollo/client';
import { NAVIGATION_MENU_ITEM_FRAGMENT } from '@/navigation-menu-item/common/graphql/fragments/navigationMenuItemFragment';
import { NAVIGATION_MENU_ITEM_QUERY_FRAGMENT } from '@/navigation-menu-item/common/graphql/fragments/navigationMenuItemQueryFragment';
export const CREATE_NAVIGATION_MENU_ITEM = gql`
${NAVIGATION_MENU_ITEM_FRAGMENT}
${NAVIGATION_MENU_ITEM_QUERY_FRAGMENT}
mutation CreateNavigationMenuItem($input: CreateNavigationMenuItemInput!) {
createNavigationMenuItem(input: $input) {
...NavigationMenuItemFields
...NavigationMenuItemQueryFields
}
}
`;
@@ -1,11 +1,11 @@
import { gql } from '@apollo/client';
import { NAVIGATION_MENU_ITEM_FRAGMENT } from '@/navigation-menu-item/common/graphql/fragments/navigationMenuItemFragment';
import { NAVIGATION_MENU_ITEM_QUERY_FRAGMENT } from '@/navigation-menu-item/common/graphql/fragments/navigationMenuItemQueryFragment';
export const DELETE_NAVIGATION_MENU_ITEM = gql`
${NAVIGATION_MENU_ITEM_FRAGMENT}
${NAVIGATION_MENU_ITEM_QUERY_FRAGMENT}
mutation DeleteNavigationMenuItem($id: UUID!) {
deleteNavigationMenuItem(id: $id) {
...NavigationMenuItemFields
...NavigationMenuItemQueryFields
}
}
`;
@@ -1,11 +1,11 @@
import { gql } from '@apollo/client';
import { NAVIGATION_MENU_ITEM_FRAGMENT } from '@/navigation-menu-item/common/graphql/fragments/navigationMenuItemFragment';
import { NAVIGATION_MENU_ITEM_QUERY_FRAGMENT } from '@/navigation-menu-item/common/graphql/fragments/navigationMenuItemQueryFragment';
export const UPDATE_NAVIGATION_MENU_ITEM = gql`
${NAVIGATION_MENU_ITEM_FRAGMENT}
${NAVIGATION_MENU_ITEM_QUERY_FRAGMENT}
mutation UpdateNavigationMenuItem($input: UpdateOneNavigationMenuItemInput!) {
updateNavigationMenuItem(input: $input) {
...NavigationMenuItemFields
...NavigationMenuItemQueryFields
}
}
`;
@@ -1,23 +1,47 @@
import { useMutation } from '@apollo/client/react';
import { NavigationMenuItemType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { CreateNavigationMenuItemDocument } from '~/generated-metadata/graphql';
import { v4 as uuidv4 } from 'uuid';
import {
type CreateNavigationMenuItemInput,
CreateNavigationMenuItemDocument,
type NavigationMenuItem,
} from '~/generated-metadata/graphql';
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
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 } = useMetadataStore();
const [createNavigationMenuItemMutation] = useMutation(
CreateNavigationMenuItemDocument,
{
refetchQueries: ['FindManyNavigationMenuItems'],
},
);
const createNavigationMenuItem = async (
@@ -26,31 +50,48 @@ export const useCreateNavigationMenuItem = () => {
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 relevantItems = folderId
? navigationMenuItems.filter((item) => item.folderId === folderId)
: navigationMenuItems.filter(
(item) =>
!isDefined(item.folderId) && isDefined(item.userWorkspaceId),
);
const input: CreateNavigationMenuItemInput = {
id,
type: NavigationMenuItemType.VIEW,
viewId: targetRecord.id,
userWorkspaceId: currentWorkspaceMemberId,
folderId,
position,
};
const maxPosition = Math.max(
...relevantItems.map((item) => item.position),
0,
);
await createNavigationMenuItemMutation({
variables: {
input: {
type: NavigationMenuItemType.VIEW,
viewId: targetRecord.id,
userWorkspaceId: currentWorkspaceMemberId,
folderId,
position: maxPosition + 1,
},
},
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,
@@ -62,30 +103,32 @@ export const useCreateNavigationMenuItem = () => {
);
}
const relevantItems = folderId
? navigationMenuItems.filter((item) => item.folderId === folderId)
: navigationMenuItems.filter(
(item) =>
!isDefined(item.folderId) && isDefined(item.userWorkspaceId),
);
const input: CreateNavigationMenuItemInput = {
id,
type: NavigationMenuItemType.RECORD,
targetRecordId: targetRecord.id,
targetObjectMetadataId: objectMetadataItem.id,
userWorkspaceId: currentWorkspaceMemberId,
folderId,
position,
};
const maxPosition = Math.max(
...relevantItems.map((item) => item.position),
0,
);
await createNavigationMenuItemMutation({
variables: {
input: {
type: NavigationMenuItemType.RECORD,
targetRecordId: targetRecord.id,
targetObjectMetadataId: objectMetadataItem.id,
userWorkspaceId: currentWorkspaceMemberId,
folderId,
position: maxPosition + 1,
},
},
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();
}
}
};
@@ -1,15 +1,19 @@
import { useMutation } from '@apollo/client/react';
import { DeleteNavigationMenuItemDocument } from '~/generated-metadata/graphql';
import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
export const useDeleteNavigationMenuItem = () => {
const { removeFromDraft, applyChanges } = useMetadataStore();
const [deleteNavigationMenuItemMutation] = useMutation(
DeleteNavigationMenuItemDocument,
{
refetchQueries: ['FindManyNavigationMenuItems'],
},
);
const deleteNavigationMenuItem = async (id: string) => {
removeFromDraft({ key: 'navigationMenuItems', itemIds: [id] });
applyChanges();
await deleteNavigationMenuItemMutation({
variables: { id },
});
@@ -1,31 +1,47 @@
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 { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore';
export const useUpdateNavigationMenuItem = () => {
const { addToDraft, applyChanges } = useMetadataStore();
const [updateNavigationMenuItemMutation] = useMutation(
UpdateNavigationMenuItemDocument,
{
refetchQueries: ['FindManyNavigationMenuItems'],
awaitRefetchQueries: false,
},
);
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,
};
await updateNavigationMenuItemMutation({
const result = await updateNavigationMenuItemMutation({
variables: { input: updateOneInput },
});
const updated = result.data?.updateNavigationMenuItem;
if (isDefined(updated)) {
addToDraft({ key: 'navigationMenuItems', items: [updated] });
applyChanges();
}
};
return { updateNavigationMenuItem };
@@ -8,7 +8,8 @@ export const navigationMenuItemsSelector = createAtomSelector<
key: 'navigationMenuItemsSelector',
get: ({ get }) => {
const entry = get(metadataStoreState, 'navigationMenuItems');
const items = entry.current as unknown as NavigationMenuItem[];
return entry.current as unknown as NavigationMenuItem[];
return items;
},
});
@@ -0,0 +1,36 @@
import { getPositionBetween } from '@/navigation-menu-item/common/utils/getPositionBetween';
export const computeDndReorderPosition = ({
sortedList,
draggableId,
destinationIndex,
}: {
sortedList: Array<{ id: string; position: number }>;
draggableId: string;
destinationIndex: number;
}): number => {
const sourceIndexInList = sortedList.findIndex(
(item) => item.id === draggableId,
);
const isSameList = sourceIndexInList >= 0;
if (isSameList) {
const listWithoutDragged = sortedList.filter(
(item) => item.id !== draggableId,
);
const adjustedIndex =
sourceIndexInList < destinationIndex &&
destinationIndex <= listWithoutDragged.length
? destinationIndex - 1
: destinationIndex;
const prevItem = listWithoutDragged[adjustedIndex - 1];
const nextItem = listWithoutDragged[adjustedIndex];
return getPositionBetween(prevItem?.position, nextItem?.position);
}
const prevItem = sortedList[destinationIndex - 1];
const nextItem = sortedList[destinationIndex];
return getPositionBetween(prevItem?.position, nextItem?.position);
};
@@ -1,45 +1,127 @@
import { type OnDragEndResponder } from '@hello-pangea/dnd';
import { useStore } from 'jotai';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { isDefined } from 'twenty-shared/utils';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/common/constants/NavigationMenuItemDroppableIds';
import { canNavigationMenuItemBeDroppedIn } from '@/navigation-menu-item/common/utils/canNavigationMenuItemBeDroppedIn';
import { useSortedNavigationMenuItems } from '@/navigation-menu-item/display/hooks/useSortedNavigationMenuItems';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { useUpdateNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useUpdateNavigationMenuItem';
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';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { calculateNewPosition } from '@/ui/layout/draggable-list/utils/calculateNewPosition';
import type { NavigationMenuItemSection } from '@/navigation-menu-item/common/types/NavigationMenuItemSection';
import { canNavigationMenuItemBeDroppedIn } from '@/navigation-menu-item/common/utils/canNavigationMenuItemBeDroppedIn';
import { computeDndReorderPosition } from '@/navigation-menu-item/common/utils/computeDndReorderPosition';
import { extractFolderIdFromDroppableId } from '@/navigation-menu-item/common/utils/extractFolderIdFromDroppableId';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/isNavigationMenuItemFolder';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData';
import { useSortedNavigationMenuItems } from '@/navigation-menu-item/display/hooks/useSortedNavigationMenuItems';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
export const useHandleNavigationMenuItemDragAndDrop = () => {
const { navigationMenuItems } = useNavigationMenuItemsData();
const matchesFolderId = (
item: { folderId?: string | null },
folderId: string | null,
): boolean => (item.folderId ?? null) === folderId;
export const useHandleNavigationMenuItemDragAndDrop = (
section: NavigationMenuItemSection,
) => {
const store = useStore();
const { navigationMenuItems, workspaceNavigationMenuItems } =
useNavigationMenuItemsData();
const { navigationMenuItemsSorted } = useSortedNavigationMenuItems();
const { updateNavigationMenuItem } = useUpdateNavigationMenuItem();
const setNavigationMenuItemsDraft = useSetAtomState(
navigationMenuItemsDraftState,
);
const setOpenNavigationMenuItemFolderIds = useSetAtomState(
openNavigationMenuItemFolderIdsState,
);
const openDestinationFolder = (folderId: string | null) => {
if (!folderId) {
return;
const isDraftMode = section === 'workspace';
const config = NAVIGATION_MENU_ITEM_SECTION_DROPPABLE_CONFIG[section];
const allItems = isDraftMode
? workspaceNavigationMenuItems
: navigationMenuItems;
const getSortedItems = (): Array<{
id: string;
position: number;
folderId?: string | null;
}> =>
isDraftMode
? (store.get(navigationMenuItemsDraftState.atom) ?? []).sort(
(a, b) => a.position - b.position,
)
: navigationMenuItemsSorted;
const applyReorder = async (
draggableId: string,
newPosition: number,
newFolderId?: string | null,
) => {
const folderUpdate =
newFolderId !== undefined ? { folderId: newFolderId } : {};
if (isDraftMode) {
const draft = store.get(navigationMenuItemsDraftState.atom);
if (!draft) return;
setNavigationMenuItemsDraft(
draft.map((item): NavigationMenuItem => {
if (item.id !== draggableId) return item;
return { ...item, position: newPosition, ...folderUpdate };
}),
);
} else {
await updateNavigationMenuItem({
id: draggableId,
position: newPosition,
...folderUpdate,
});
}
};
const computeAndApplyReorder = async (
draggableId: string,
list: Array<{ id: string; position: number }>,
destinationIndex: number,
newFolderId?: string | null,
) => {
const newPosition = computeDndReorderPosition({
sortedList: list,
draggableId,
destinationIndex,
});
await applyReorder(draggableId, newPosition, newFolderId);
};
const isDropAllowed = (
sourceDroppableId: string,
destinationDroppableId: string,
): boolean => {
if (isDraftMode) {
return (
sourceDroppableId.startsWith('workspace-') &&
destinationDroppableId.startsWith('workspace-') &&
store.get(isLayoutCustomizationModeEnabledState.atom) &&
isDefined(store.get(navigationMenuItemsDraftState.atom))
);
}
setOpenNavigationMenuItemFolderIds((current) => {
if (!current.includes(folderId)) {
return [...current, folderId];
}
return current;
return !canNavigationMenuItemBeDroppedIn({
navigationMenuItemSection: 'workspace',
droppableId: destinationDroppableId,
});
};
const handleNavigationMenuItemDragAndDrop: OnDragEndResponder = async (
result,
result: Parameters<OnDragEndResponder>[0] & {
insertBeforeItemId?: string | null;
},
) => {
const { destination, source, draggableId } = result;
if (!destination) {
return;
}
if (!destination) return;
if (
destination.droppableId === source.droppableId &&
@@ -48,104 +130,83 @@ export const useHandleNavigationMenuItemDragAndDrop = () => {
return;
}
if (
canNavigationMenuItemBeDroppedIn({
navigationMenuItemSection: 'workspace',
droppableId: destination.droppableId,
})
) {
return;
}
if (!isDropAllowed(source.droppableId, destination.droppableId)) return;
const draggedNavigationMenuItem = navigationMenuItems.find(
(item) => item.id === draggableId,
);
if (!draggedNavigationMenuItem) {
return;
}
const draggedItem = allItems.find((item) => item.id === draggableId);
if (!draggedItem) return;
const destinationFolderId = extractFolderIdFromDroppableId(
destination.droppableId,
'favorite',
section,
);
const sourceFolderId = extractFolderIdFromDroppableId(
source.droppableId,
'favorite',
section,
);
if (
destination.droppableId.startsWith(
NavigationMenuItemDroppableIds.FAVORITE_FOLDER_HEADER_PREFIX,
)
isDraftMode &&
isNavigationMenuItemFolder(draggedItem) &&
isDefined(destinationFolderId)
) {
if (destinationFolderId === null)
throw new Error('Invalid folder header ID');
const folderNavigationMenuItems = navigationMenuItemsSorted.filter(
(item) => item.folderId === destinationFolderId,
);
const newPosition =
folderNavigationMenuItems.length === 0
? 1
: folderNavigationMenuItems[folderNavigationMenuItems.length - 1]
.position + 1;
await updateNavigationMenuItem({
id: draggableId,
folderId: destinationFolderId,
position: newPosition,
});
openDestinationFolder(destinationFolderId);
return;
}
if (destination.droppableId !== source.droppableId) {
const destinationNavigationMenuItems = navigationMenuItemsSorted.filter(
(item) => item.folderId === destinationFolderId,
);
let newPosition;
if (destinationNavigationMenuItems.length === 0) {
newPosition = 1;
} else if (destination.index === 0) {
newPosition = destinationNavigationMenuItems[0].position - 1;
} else if (destination.index >= destinationNavigationMenuItems.length) {
newPosition =
destinationNavigationMenuItems[
destinationNavigationMenuItems.length - 1
].position + 1;
} else {
newPosition = calculateNewPosition({
destinationIndex: destination.index,
sourceIndex: -1,
items: destinationNavigationMenuItems,
});
if (destination.droppableId.startsWith(config.folderHeaderPrefix)) {
if (destinationFolderId === null) {
throw new Error('Invalid folder header ID');
}
await updateNavigationMenuItem({
id: draggableId,
folderId: destinationFolderId ?? null,
position: newPosition,
});
const folderList = getSortedItems().filter((item) =>
matchesFolderId(item, destinationFolderId),
);
await computeAndApplyReorder(
draggableId,
folderList,
folderList.length,
destinationFolderId,
);
setOpenNavigationMenuItemFolderIds((current) =>
current.includes(destinationFolderId)
? current
: [...current, destinationFolderId],
);
return;
}
const navigationMenuItemsInSameList = navigationMenuItemsSorted
.filter((item) => item.folderId === sourceFolderId)
.filter((item) => item.id !== draggableId);
if (sourceFolderId === destinationFolderId) {
const sourceList = getSortedItems().filter((item) =>
matchesFolderId(item, sourceFolderId),
);
const newPosition = calculateNewPosition({
destinationIndex: destination.index,
sourceIndex: source.index,
items: navigationMenuItemsInSameList,
});
if (!sourceList.some((item) => item.id === draggableId)) return;
await updateNavigationMenuItem({
id: draggableId,
position: newPosition,
});
const insertBeforeIndex =
result.insertBeforeItemId != null
? sourceList.findIndex(
(item) => item.id === result.insertBeforeItemId,
)
: -1;
await computeAndApplyReorder(
draggableId,
sourceList,
insertBeforeIndex >= 0 ? insertBeforeIndex : destination.index,
);
return;
}
const destinationList = getSortedItems().filter((item) =>
matchesFolderId(item, destinationFolderId),
);
await computeAndApplyReorder(
draggableId,
destinationList,
destination.index,
destinationFolderId ?? null,
);
};
return { handleNavigationMenuItemDragAndDrop };
@@ -1,177 +0,0 @@
import { type OnDragEndResponder } from '@hello-pangea/dnd';
import { useStore } from 'jotai';
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/common/constants/NavigationMenuItemDroppableIds';
import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState';
import { openNavigationMenuItemFolderIdsState } from '@/navigation-menu-item/common/states/openNavigationMenuItemFolderIdsState';
import { getPositionBetween } from '@/navigation-menu-item/common/utils/getPositionBetween';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/isNavigationMenuItemFolder';
import {
matchesWorkspaceFolderId,
validateAndExtractWorkspaceFolderId,
} from '@/navigation-menu-item/common/utils/validateAndExtractWorkspaceFolderId';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { isDefined } from 'twenty-shared/utils';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData';
export const useHandleWorkspaceNavigationMenuItemDragAndDrop = () => {
const store = useStore();
const { workspaceNavigationMenuItems } = useNavigationMenuItemsData();
const setNavigationMenuItemsDraft = useSetAtomState(
navigationMenuItemsDraftState,
);
const setOpenNavigationMenuItemFolderIds = useSetAtomState(
openNavigationMenuItemFolderIdsState,
);
const openDestinationFolder = (folderId: string | null) => {
if (!folderId) {
return;
}
setOpenNavigationMenuItemFolderIds((current) => {
if (!current.includes(folderId)) {
return [...current, folderId];
}
return current;
});
};
const handleWorkspaceNavigationMenuItemDragAndDrop: OnDragEndResponder = (
result: Parameters<OnDragEndResponder>[0] & {
insertBeforeItemId?: string | null;
},
) => {
const { destination, source, draggableId } = result;
if (!destination) {
return;
}
if (
destination.droppableId === source.droppableId &&
destination.index === source.index
) {
return;
}
const isWorkspaceDrop =
source.droppableId.startsWith('workspace-') &&
destination.droppableId.startsWith('workspace-');
if (!isWorkspaceDrop) {
return;
}
const navigationMenuItemsDraft = store.get(
navigationMenuItemsDraftState.atom,
);
const isLayoutCustomizationModeEnabled = store.get(
isLayoutCustomizationModeEnabledState.atom,
);
if (!isLayoutCustomizationModeEnabled || !navigationMenuItemsDraft) {
return;
}
const draggedItem = workspaceNavigationMenuItems.find(
(item) => item.id === draggableId,
);
if (!draggedItem) {
return;
}
const destinationFolderId = validateAndExtractWorkspaceFolderId(
destination.droppableId,
);
if (
isNavigationMenuItemFolder(draggedItem) &&
isDefined(destinationFolderId)
) {
return;
}
const sourceFolderId = validateAndExtractWorkspaceFolderId(
source.droppableId,
);
const isDropOnFolderHeader = destination.droppableId.startsWith(
NavigationMenuItemDroppableIds.WORKSPACE_FOLDER_HEADER_PREFIX,
);
if (isDropOnFolderHeader && isDefined(destinationFolderId)) {
openDestinationFolder(destinationFolderId);
}
const sourceList = (navigationMenuItemsDraft ?? [])
.filter((item) => matchesWorkspaceFolderId(item, sourceFolderId))
.sort((a, b) => a.position - b.position);
const destinationList = (navigationMenuItemsDraft ?? [])
.filter((item) => matchesWorkspaceFolderId(item, destinationFolderId))
.sort((a, b) => a.position - b.position);
if (!sourceList.some((item) => item.id === draggableId)) {
return;
}
const isSameList = sourceFolderId === destinationFolderId;
if (isSameList) {
const listWithoutDragged = sourceList.filter(
(item) => item.id !== draggableId,
);
const sourceIndexInList = sourceList.findIndex(
(item) => item.id === draggableId,
);
const insertBeforeIndex =
result.insertBeforeItemId != null
? sourceList.findIndex(
(item) => item.id === result.insertBeforeItemId,
)
: -1;
const destinationIndexInFullList =
insertBeforeIndex >= 0 ? insertBeforeIndex : destination.index;
const destIndexInListWithoutDragged =
sourceIndexInList < destinationIndexInFullList &&
destinationIndexInFullList <= listWithoutDragged.length
? destinationIndexInFullList - 1
: destinationIndexInFullList;
const prevItem = listWithoutDragged[destIndexInListWithoutDragged - 1];
const nextItem = listWithoutDragged[destIndexInListWithoutDragged];
const newPosition = getPositionBetween(
prevItem?.position,
nextItem?.position,
);
const updatedDraft = navigationMenuItemsDraft.map(
(item): NavigationMenuItem =>
item.id === draggableId ? { ...item, position: newPosition } : item,
);
setNavigationMenuItemsDraft(updatedDraft);
return;
}
const prevItem = destinationList[destination.index - 1];
const nextItem = destinationList[destination.index];
const newPosition = getPositionBetween(
prevItem?.position,
nextItem?.position,
);
const updatedDraft = navigationMenuItemsDraft.map(
(item): NavigationMenuItem => {
if (item.id !== draggableId) return item;
return {
...item,
position: newPosition,
folderId: destinationFolderId,
};
},
);
setNavigationMenuItemsDraft(updatedDraft);
};
return { handleWorkspaceNavigationMenuItemDragAndDrop };
};
@@ -18,7 +18,6 @@ import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/
import { DROP_RESULT_OPTIONS } from '@/navigation-menu-item/display/dnd/constants/navigationMenuItemDndKitDropResultOptions';
import { useHandleAddToNavigationDrop } from '@/navigation-menu-item/display/dnd/hooks/useHandleAddToNavigationDrop';
import { useHandleNavigationMenuItemDragAndDrop } from '@/navigation-menu-item/display/dnd/hooks/useHandleNavigationMenuItemDragAndDrop';
import { useHandleWorkspaceNavigationMenuItemDragAndDrop } from '@/navigation-menu-item/display/dnd/hooks/useHandleWorkspaceNavigationMenuItemDragAndDrop';
import { resolveDropTarget } from '@/navigation-menu-item/display/dnd/utils/navigationMenuItemDndKitResolveDropTarget';
import { toDropResult } from '@/navigation-menu-item/display/dnd/utils/navigationMenuItemDndKitToDropResult';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData';
@@ -92,9 +91,7 @@ export const useNavigationMenuItemDndKit = (
const { workspaceNavigationMenuItems } = useNavigationMenuItemsDraftState();
const { handleAddToNavigationDrop } = useHandleAddToNavigationDrop();
const { handleNavigationMenuItemDragAndDrop } =
useHandleNavigationMenuItemDragAndDrop();
const { handleWorkspaceNavigationMenuItemDragAndDrop } =
useHandleWorkspaceNavigationMenuItemDragAndDrop();
useHandleNavigationMenuItemDragAndDrop(sectionType);
const items = isWorkspaceSection
? workspaceNavigationMenuItems
@@ -169,25 +166,13 @@ export const useNavigationMenuItemDndKit = (
[sectionType, getAddToNavPayload, isSourceFolderDrag],
);
const applyWorkspaceReorderIfAllowed = useCallback(
const applyWorkspaceReorder = useCallback(
(
id: string,
source: DropDestination,
destination: DropDestination,
insertBeforeItemId?: string | null,
) => {
const draggedItem = getNavItemById(id);
const destFolderId = extractFolderIdFromDroppableId(
destination.droppableId,
'workspace',
);
if (
isDefined(destFolderId) &&
isDefined(draggedItem) &&
isNavigationMenuItemFolder(draggedItem)
) {
return;
}
const result = toDropResult(
id,
{
@@ -197,7 +182,7 @@ export const useNavigationMenuItemDndKit = (
destination,
);
const provided: ResponderProvided = { announce: () => {} };
handleWorkspaceNavigationMenuItemDragAndDrop(
handleNavigationMenuItemDragAndDrop(
{
...result,
...DROP_RESULT_OPTIONS,
@@ -206,7 +191,7 @@ export const useNavigationMenuItemDndKit = (
provided,
);
},
[getNavItemById, handleWorkspaceNavigationMenuItemDragAndDrop],
[handleNavigationMenuItemDragAndDrop],
);
const handleDragStart = (event: DragStartPayload) => {
@@ -344,7 +329,7 @@ export const useNavigationMenuItemDndKit = (
const insertBeforeItemId = resolved.isTargetFolder
? null
: String(target?.id ?? '');
applyWorkspaceReorderIfAllowed(
applyWorkspaceReorder(
draggableId,
{ droppableId: initialGroup, index: initialIndex },
resolved.destination,
@@ -388,7 +373,7 @@ export const useNavigationMenuItemDndKit = (
droppableId: destination.droppableId,
})
) {
applyWorkspaceReorderIfAllowed(
applyWorkspaceReorder(
draggableId,
{
droppableId: data?.sourceDroppableId ?? '',
@@ -58,7 +58,7 @@ const StyledFolderContainer = styled.div<{
border: ${({ $isSelectedInEditMode }) =>
$isSelectedInEditMode
? `1px solid ${themeCssVariables.color.blue}`
: '1px solid transparent'};
: 'none'};
border-radius: ${themeCssVariables.border.radius.sm};
transition: background-color 150ms ease-in-out;
@@ -240,7 +240,6 @@ export const NavigationMenuItemFolderDnd = ({
triggerEvent="CLICK"
preventCollapseOnMobile={isMobile}
isDragging={isDragging}
alwaysShowRightOptions
/>
);
@@ -11,9 +11,6 @@ export const useCreateNavigationMenuItemFolder = () => {
const [createNavigationMenuItemMutation] = useMutation(
CreateNavigationMenuItemDocument,
{
refetchQueries: ['FindManyNavigationMenuItems'],
},
);
const createNewNavigationMenuItemFolder = async (
@@ -23,20 +20,16 @@ export const useCreateNavigationMenuItemFolder = () => {
return;
}
const folderNavigationMenuItems = navigationMenuItems.filter(
const topLevelItems = navigationMenuItems.filter(
(item) =>
isDefined(item.name) &&
!item.folderId &&
!item.targetRecordId &&
!item.targetObjectMetadataId &&
!item.viewId &&
!isDefined(item.folderId) &&
item.userWorkspaceId === currentWorkspaceMemberId,
);
const maxPosition = Math.max(
...folderNavigationMenuItems.map((item) => item.position),
0,
);
const minPosition =
topLevelItems.length > 0
? Math.min(...topLevelItems.map((item) => item.position))
: 1;
await createNavigationMenuItemMutation({
variables: {
@@ -47,7 +40,7 @@ export const useCreateNavigationMenuItemFolder = () => {
targetObjectMetadataId: null,
userWorkspaceId: currentWorkspaceMemberId,
folderId: null,
position: maxPosition + 1,
position: minPosition - 1,
},
},
});
@@ -23,9 +23,6 @@ export const useSaveNavigationMenuItemsDraft = () => {
const { deleteNavigationMenuItem } = useDeleteNavigationMenuItem();
const [createNavigationMenuItemMutation] = useMutation(
CreateNavigationMenuItemDocument,
{
refetchQueries: ['FindManyNavigationMenuItems'],
},
);
const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem();
@@ -1236,6 +1236,47 @@ enum PageLayoutType {
DASHBOARD
}
type FileWithSignedUrl {
id: UUID!
path: String!
size: Float!
createdAt: DateTime!
url: String!
}
type RecordIdentifier {
id: UUID!
labelIdentifier: String!
imageIdentifier: String
}
type NavigationMenuItem {
id: UUID!
userWorkspaceId: UUID
targetRecordId: UUID
targetObjectMetadataId: UUID
viewId: UUID
type: NavigationMenuItemType!
name: String
link: String
icon: String
color: String
folderId: UUID
position: Float!
applicationId: UUID
createdAt: DateTime!
updatedAt: DateTime!
targetRecordIdentifier: RecordIdentifier
}
enum NavigationMenuItemType {
VIEW
FOLDER
LINK
OBJECT
RECORD
}
type ObjectRecordEventProperties {
updatedFields: [String!]
before: JSON
@@ -1482,14 +1523,6 @@ type ApprovedAccessDomain {
createdAt: DateTime!
}
type FileWithSignedUrl {
id: UUID!
path: String!
size: Float!
createdAt: DateTime!
url: String!
}
type WorkspaceInvitation {
id: UUID!
email: String!
@@ -1924,39 +1957,6 @@ type WorkspaceInviteHashValid {
isValid: Boolean!
}
type RecordIdentifier {
id: UUID!
labelIdentifier: String!
imageIdentifier: String
}
type NavigationMenuItem {
id: UUID!
userWorkspaceId: UUID
targetRecordId: UUID
targetObjectMetadataId: UUID
viewId: UUID
type: NavigationMenuItemType!
name: String
link: String
icon: String
color: String
folderId: UUID
position: Float!
applicationId: UUID
createdAt: DateTime!
updatedAt: DateTime!
targetRecordIdentifier: RecordIdentifier
}
enum NavigationMenuItemType {
VIEW
FOLDER
LINK
OBJECT
RECORD
}
type LogicFunctionExecutionResult {
"""Execution result in JSON format"""
data: JSON
@@ -2925,6 +2925,8 @@ type EventLogQueryResult {
}
type Query {
navigationMenuItems: [NavigationMenuItem!]!
navigationMenuItem(id: UUID!): NavigationMenuItem
getPageLayoutWidgets(pageLayoutTabId: String!): [PageLayoutWidget!]!
getPageLayoutWidget(id: String!): PageLayoutWidget!
getPageLayoutTabs(pageLayoutId: String!): [PageLayoutTab!]!
@@ -2978,13 +2980,11 @@ type Query {
enterprisePortalSession(returnUrlPath: String): String
enterpriseCheckoutSession(billingInterval: String): String
enterpriseSubscriptionStatus: EnterpriseSubscriptionStatusDTO
navigationMenuItems: [NavigationMenuItem!]!
navigationMenuItem(id: UUID!): NavigationMenuItem
apiKeys: [ApiKey!]!
apiKey(input: GetApiKeyInput!): ApiKey
getRoles: [Role!]!
findWorkspaceInvitations: [WorkspaceInvitation!]!
getApprovedAccessDomains: [ApprovedAccessDomain!]!
apiKeys: [ApiKey!]!
apiKey(input: GetApiKeyInput!): ApiKey
getToolIndex: [ToolIndexEntry!]!
getToolInputSchema(toolName: String!): JSON
field(
@@ -3171,6 +3171,15 @@ input BarChartDataInput {
type Mutation {
addQueryToEventStream(input: AddQuerySubscriptionInput!): Boolean!
removeQueryFromEventStream(input: RemoveQueryFromEventStreamInput!): Boolean!
createNavigationMenuItem(input: CreateNavigationMenuItemInput!): NavigationMenuItem!
updateNavigationMenuItem(input: UpdateOneNavigationMenuItemInput!): NavigationMenuItem!
deleteNavigationMenuItem(id: UUID!): NavigationMenuItem!
uploadAIChatFile(file: Upload!): FileWithSignedUrl!
uploadWorkflowFile(file: Upload!): FileWithSignedUrl!
uploadWorkspaceLogo(file: Upload!): FileWithSignedUrl!
uploadWorkspaceMemberProfilePicture(file: Upload!): FileWithSignedUrl!
uploadFilesFieldFile(file: Upload!, fieldMetadataId: String!): FileWithSignedUrl!
uploadFilesFieldFileByUniversalIdentifier(file: Upload!, fieldMetadataUniversalIdentifier: String!): FileWithSignedUrl!
createObjectEvent(event: String!, recordId: UUID!, objectMetadataId: UUID!, properties: JSON): Analytics!
trackAnalytics(type: AnalyticsType!, name: String, event: String, properties: JSON): Analytics!
createPageLayoutWidget(input: CreatePageLayoutWidgetInput!): PageLayoutWidget!
@@ -3218,12 +3227,6 @@ type Mutation {
createOneAgent(input: CreateAgentInput!): Agent!
updateOneAgent(input: UpdateAgentInput!): Agent!
deleteOneAgent(input: AgentIdInput!): Agent!
uploadAIChatFile(file: Upload!): FileWithSignedUrl!
uploadWorkflowFile(file: Upload!): FileWithSignedUrl!
uploadWorkspaceLogo(file: Upload!): FileWithSignedUrl!
uploadWorkspaceMemberProfilePicture(file: Upload!): FileWithSignedUrl!
uploadFilesFieldFile(file: Upload!, fieldMetadataId: String!): FileWithSignedUrl!
uploadFilesFieldFileByUniversalIdentifier(file: Upload!, fieldMetadataUniversalIdentifier: String!): FileWithSignedUrl!
checkoutSession(recurringInterval: SubscriptionInterval!, plan: BillingPlanKey! = PRO, requirePaymentMethod: Boolean! = true, successUrlPath: String): BillingSession!
switchSubscriptionInterval: BillingUpdate!
switchBillingPlan: BillingUpdate!
@@ -3234,13 +3237,6 @@ type Mutation {
cancelSwitchMeteredPrice: BillingUpdate!
refreshEnterpriseValidityToken: Boolean!
setEnterpriseKey(enterpriseKey: String!): EnterpriseLicenseInfoDTO!
createNavigationMenuItem(input: CreateNavigationMenuItemInput!): NavigationMenuItem!
updateNavigationMenuItem(input: UpdateOneNavigationMenuItemInput!): NavigationMenuItem!
deleteNavigationMenuItem(id: UUID!): NavigationMenuItem!
createApiKey(input: CreateApiKeyInput!): ApiKey!
updateApiKey(input: UpdateApiKeyInput!): ApiKey
revokeApiKey(input: RevokeApiKeyInput!): ApiKey
assignRoleToApiKey(apiKeyId: UUID!, roleId: UUID!): Boolean!
updateWorkspaceMemberRole(workspaceMemberId: UUID!, roleId: UUID!): WorkspaceMember!
createOneRole(createRoleInput: CreateRoleInput!): Role!
updateOneRole(updateRoleInput: UpdateRoleInput!): Role!
@@ -3259,6 +3255,10 @@ type Mutation {
createApprovedAccessDomain(input: CreateApprovedAccessDomainInput!): ApprovedAccessDomain!
deleteApprovedAccessDomain(input: DeleteApprovedAccessDomainInput!): Boolean!
validateApprovedAccessDomain(input: ValidateApprovedAccessDomainInput!): ApprovedAccessDomain!
createApiKey(input: CreateApiKeyInput!): ApiKey!
updateApiKey(input: UpdateApiKeyInput!): ApiKey
revokeApiKey(input: RevokeApiKeyInput!): ApiKey
assignRoleToApiKey(apiKeyId: UUID!, roleId: UUID!): Boolean!
createOneField(input: CreateOneFieldMetadataInput!): Field!
updateOneField(input: UpdateOneFieldMetadataInput!): Field!
deleteOneField(input: DeleteOneFieldInput!): Field!
@@ -3373,6 +3373,41 @@ input RemoveQueryFromEventStreamInput {
queryId: String!
}
input CreateNavigationMenuItemInput {
id: UUID
userWorkspaceId: UUID
targetRecordId: UUID
targetObjectMetadataId: UUID
viewId: UUID
type: NavigationMenuItemType!
name: String
link: String
icon: String
color: String
folderId: UUID
position: Float
}
input UpdateOneNavigationMenuItemInput {
"""The id of the record to update"""
id: UUID!
"""The record to update"""
update: UpdateNavigationMenuItemInput!
}
input UpdateNavigationMenuItemInput {
folderId: UUID
position: Float
name: String
link: String
icon: String
color: String
}
"""The `Upload` scalar type represents a file upload."""
scalar Upload
enum AnalyticsType {
PAGEVIEW
TRACK
@@ -3793,58 +3828,6 @@ input UpdateAgentInput {
evaluationInputs: [String!]
}
"""The `Upload` scalar type represents a file upload."""
scalar Upload
input CreateNavigationMenuItemInput {
userWorkspaceId: UUID
targetRecordId: UUID
targetObjectMetadataId: UUID
viewId: UUID
type: NavigationMenuItemType!
name: String
link: String
icon: String
color: String
folderId: UUID
position: Float
}
input UpdateOneNavigationMenuItemInput {
"""The id of the record to update"""
id: UUID!
"""The record to update"""
update: UpdateNavigationMenuItemInput!
}
input UpdateNavigationMenuItemInput {
folderId: UUID
position: Float
name: String
link: String
icon: String
color: String
}
input CreateApiKeyInput {
name: String!
expiresAt: String!
revokedAt: String
roleId: UUID!
}
input UpdateApiKeyInput {
id: UUID!
name: String
expiresAt: String
revokedAt: String
}
input RevokeApiKeyInput {
id: UUID!
}
input CreateRoleInput {
id: String
label: String!
@@ -3954,6 +3937,24 @@ input ValidateApprovedAccessDomainInput {
approvedAccessDomainId: UUID!
}
input CreateApiKeyInput {
name: String!
expiresAt: String!
revokedAt: String
roleId: UUID!
}
input UpdateApiKeyInput {
id: UUID!
name: String
expiresAt: String
revokedAt: String
}
input RevokeApiKeyInput {
id: UUID!
}
input CreateOneFieldMetadataInput {
"""The record to create"""
field: CreateFieldInput!
@@ -963,6 +963,44 @@ export interface PageLayout {
export type PageLayoutType = 'RECORD_INDEX' | 'RECORD_PAGE' | 'DASHBOARD'
export interface FileWithSignedUrl {
id: Scalars['UUID']
path: Scalars['String']
size: Scalars['Float']
createdAt: Scalars['DateTime']
url: Scalars['String']
__typename: 'FileWithSignedUrl'
}
export interface RecordIdentifier {
id: Scalars['UUID']
labelIdentifier: Scalars['String']
imageIdentifier?: Scalars['String']
__typename: 'RecordIdentifier'
}
export interface NavigationMenuItem {
id: Scalars['UUID']
userWorkspaceId?: Scalars['UUID']
targetRecordId?: Scalars['UUID']
targetObjectMetadataId?: Scalars['UUID']
viewId?: Scalars['UUID']
type: NavigationMenuItemType
name?: Scalars['String']
link?: Scalars['String']
icon?: Scalars['String']
color?: Scalars['String']
folderId?: Scalars['UUID']
position: Scalars['Float']
applicationId?: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
targetRecordIdentifier?: RecordIdentifier
__typename: 'NavigationMenuItem'
}
export type NavigationMenuItemType = 'VIEW' | 'FOLDER' | 'LINK' | 'OBJECT' | 'RECORD'
export interface ObjectRecordEventProperties {
updatedFields?: Scalars['String'][]
before?: Scalars['JSON']
@@ -1203,15 +1241,6 @@ export interface ApprovedAccessDomain {
__typename: 'ApprovedAccessDomain'
}
export interface FileWithSignedUrl {
id: Scalars['UUID']
path: Scalars['String']
size: Scalars['Float']
createdAt: Scalars['DateTime']
url: Scalars['String']
__typename: 'FileWithSignedUrl'
}
export interface WorkspaceInvitation {
id: Scalars['UUID']
email: Scalars['String']
@@ -1658,35 +1687,6 @@ export interface WorkspaceInviteHashValid {
__typename: 'WorkspaceInviteHashValid'
}
export interface RecordIdentifier {
id: Scalars['UUID']
labelIdentifier: Scalars['String']
imageIdentifier?: Scalars['String']
__typename: 'RecordIdentifier'
}
export interface NavigationMenuItem {
id: Scalars['UUID']
userWorkspaceId?: Scalars['UUID']
targetRecordId?: Scalars['UUID']
targetObjectMetadataId?: Scalars['UUID']
viewId?: Scalars['UUID']
type: NavigationMenuItemType
name?: Scalars['String']
link?: Scalars['String']
icon?: Scalars['String']
color?: Scalars['String']
folderId?: Scalars['UUID']
position: Scalars['Float']
applicationId?: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
targetRecordIdentifier?: RecordIdentifier
__typename: 'NavigationMenuItem'
}
export type NavigationMenuItemType = 'VIEW' | 'FOLDER' | 'LINK' | 'OBJECT' | 'RECORD'
export interface LogicFunctionExecutionResult {
/** Execution result in JSON format */
data?: Scalars['JSON']
@@ -2565,6 +2565,8 @@ export interface EventLogQueryResult {
}
export interface Query {
navigationMenuItems: NavigationMenuItem[]
navigationMenuItem?: NavigationMenuItem
getPageLayoutWidgets: PageLayoutWidget[]
getPageLayoutWidget: PageLayoutWidget
getPageLayoutTabs: PageLayoutTab[]
@@ -2600,13 +2602,11 @@ export interface Query {
enterprisePortalSession?: Scalars['String']
enterpriseCheckoutSession?: Scalars['String']
enterpriseSubscriptionStatus?: EnterpriseSubscriptionStatusDTO
navigationMenuItems: NavigationMenuItem[]
navigationMenuItem?: NavigationMenuItem
apiKeys: ApiKey[]
apiKey?: ApiKey
getRoles: Role[]
findWorkspaceInvitations: WorkspaceInvitation[]
getApprovedAccessDomains: ApprovedAccessDomain[]
apiKeys: ApiKey[]
apiKey?: ApiKey
getToolIndex: ToolIndexEntry[]
getToolInputSchema?: Scalars['JSON']
field: Field
@@ -2683,6 +2683,15 @@ export type EventLogTable = 'WORKSPACE_EVENT' | 'PAGEVIEW' | 'OBJECT_EVENT'
export interface Mutation {
addQueryToEventStream: Scalars['Boolean']
removeQueryFromEventStream: Scalars['Boolean']
createNavigationMenuItem: NavigationMenuItem
updateNavigationMenuItem: NavigationMenuItem
deleteNavigationMenuItem: NavigationMenuItem
uploadAIChatFile: FileWithSignedUrl
uploadWorkflowFile: FileWithSignedUrl
uploadWorkspaceLogo: FileWithSignedUrl
uploadWorkspaceMemberProfilePicture: FileWithSignedUrl
uploadFilesFieldFile: FileWithSignedUrl
uploadFilesFieldFileByUniversalIdentifier: FileWithSignedUrl
createObjectEvent: Analytics
trackAnalytics: Analytics
createPageLayoutWidget: PageLayoutWidget
@@ -2730,12 +2739,6 @@ export interface Mutation {
createOneAgent: Agent
updateOneAgent: Agent
deleteOneAgent: Agent
uploadAIChatFile: FileWithSignedUrl
uploadWorkflowFile: FileWithSignedUrl
uploadWorkspaceLogo: FileWithSignedUrl
uploadWorkspaceMemberProfilePicture: FileWithSignedUrl
uploadFilesFieldFile: FileWithSignedUrl
uploadFilesFieldFileByUniversalIdentifier: FileWithSignedUrl
checkoutSession: BillingSession
switchSubscriptionInterval: BillingUpdate
switchBillingPlan: BillingUpdate
@@ -2746,13 +2749,6 @@ export interface Mutation {
cancelSwitchMeteredPrice: BillingUpdate
refreshEnterpriseValidityToken: Scalars['Boolean']
setEnterpriseKey: EnterpriseLicenseInfoDTO
createNavigationMenuItem: NavigationMenuItem
updateNavigationMenuItem: NavigationMenuItem
deleteNavigationMenuItem: NavigationMenuItem
createApiKey: ApiKey
updateApiKey?: ApiKey
revokeApiKey?: ApiKey
assignRoleToApiKey: Scalars['Boolean']
updateWorkspaceMemberRole: WorkspaceMember
createOneRole: Role
updateOneRole: Role
@@ -2771,6 +2767,10 @@ export interface Mutation {
createApprovedAccessDomain: ApprovedAccessDomain
deleteApprovedAccessDomain: Scalars['Boolean']
validateApprovedAccessDomain: ApprovedAccessDomain
createApiKey: ApiKey
updateApiKey?: ApiKey
revokeApiKey?: ApiKey
assignRoleToApiKey: Scalars['Boolean']
createOneField: Field
updateOneField: Field
deleteOneField: Field
@@ -3898,6 +3898,45 @@ export interface PageLayoutGenqlSelection{
__scalar?: boolean | number
}
export interface FileWithSignedUrlGenqlSelection{
id?: boolean | number
path?: boolean | number
size?: boolean | number
createdAt?: boolean | number
url?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface RecordIdentifierGenqlSelection{
id?: boolean | number
labelIdentifier?: boolean | number
imageIdentifier?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface NavigationMenuItemGenqlSelection{
id?: boolean | number
userWorkspaceId?: boolean | number
targetRecordId?: boolean | number
targetObjectMetadataId?: boolean | number
viewId?: boolean | number
type?: boolean | number
name?: boolean | number
link?: boolean | number
icon?: boolean | number
color?: boolean | number
folderId?: boolean | number
position?: boolean | number
applicationId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
targetRecordIdentifier?: RecordIdentifierGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ObjectRecordEventPropertiesGenqlSelection{
updatedFields?: boolean | number
before?: boolean | number
@@ -4143,16 +4182,6 @@ export interface ApprovedAccessDomainGenqlSelection{
__scalar?: boolean | number
}
export interface FileWithSignedUrlGenqlSelection{
id?: boolean | number
path?: boolean | number
size?: boolean | number
createdAt?: boolean | number
url?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface WorkspaceInvitationGenqlSelection{
id?: boolean | number
email?: boolean | number
@@ -4648,35 +4677,6 @@ export interface WorkspaceInviteHashValidGenqlSelection{
__scalar?: boolean | number
}
export interface RecordIdentifierGenqlSelection{
id?: boolean | number
labelIdentifier?: boolean | number
imageIdentifier?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface NavigationMenuItemGenqlSelection{
id?: boolean | number
userWorkspaceId?: boolean | number
targetRecordId?: boolean | number
targetObjectMetadataId?: boolean | number
viewId?: boolean | number
type?: boolean | number
name?: boolean | number
link?: boolean | number
icon?: boolean | number
color?: boolean | number
folderId?: boolean | number
position?: boolean | number
applicationId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
targetRecordIdentifier?: RecordIdentifierGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface LogicFunctionExecutionResultGenqlSelection{
/** Execution result in JSON format */
data?: boolean | number
@@ -5607,6 +5607,8 @@ export interface EventLogQueryResultGenqlSelection{
}
export interface QueryGenqlSelection{
navigationMenuItems?: NavigationMenuItemGenqlSelection
navigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {id: Scalars['UUID']} })
getPageLayoutWidgets?: (PageLayoutWidgetGenqlSelection & { __args: {pageLayoutTabId: Scalars['String']} })
getPageLayoutWidget?: (PageLayoutWidgetGenqlSelection & { __args: {id: Scalars['String']} })
getPageLayoutTabs?: (PageLayoutTabGenqlSelection & { __args: {pageLayoutId: Scalars['String']} })
@@ -5654,13 +5656,11 @@ export interface QueryGenqlSelection{
enterprisePortalSession?: { __args: {returnUrlPath?: (Scalars['String'] | null)} } | boolean | number
enterpriseCheckoutSession?: { __args: {billingInterval?: (Scalars['String'] | null)} } | boolean | number
enterpriseSubscriptionStatus?: EnterpriseSubscriptionStatusDTOGenqlSelection
navigationMenuItems?: NavigationMenuItemGenqlSelection
navigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {id: Scalars['UUID']} })
apiKeys?: ApiKeyGenqlSelection
apiKey?: (ApiKeyGenqlSelection & { __args: {input: GetApiKeyInput} })
getRoles?: RoleGenqlSelection
findWorkspaceInvitations?: WorkspaceInvitationGenqlSelection
getApprovedAccessDomains?: ApprovedAccessDomainGenqlSelection
apiKeys?: ApiKeyGenqlSelection
apiKey?: (ApiKeyGenqlSelection & { __args: {input: GetApiKeyInput} })
getToolIndex?: ToolIndexEntryGenqlSelection
getToolInputSchema?: { __args: {toolName: Scalars['String']} }
field?: (FieldGenqlSelection & { __args: {
@@ -5768,6 +5768,15 @@ export interface BarChartDataInput {objectMetadataId: Scalars['UUID'],configurat
export interface MutationGenqlSelection{
addQueryToEventStream?: { __args: {input: AddQuerySubscriptionInput} }
removeQueryFromEventStream?: { __args: {input: RemoveQueryFromEventStreamInput} }
createNavigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {input: CreateNavigationMenuItemInput} })
updateNavigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {input: UpdateOneNavigationMenuItemInput} })
deleteNavigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {id: Scalars['UUID']} })
uploadAIChatFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })
uploadWorkflowFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })
uploadWorkspaceLogo?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })
uploadWorkspaceMemberProfilePicture?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })
uploadFilesFieldFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload'], fieldMetadataId: Scalars['String']} })
uploadFilesFieldFileByUniversalIdentifier?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload'], fieldMetadataUniversalIdentifier: Scalars['String']} })
createObjectEvent?: (AnalyticsGenqlSelection & { __args: {event: Scalars['String'], recordId: Scalars['UUID'], objectMetadataId: Scalars['UUID'], properties?: (Scalars['JSON'] | null)} })
trackAnalytics?: (AnalyticsGenqlSelection & { __args: {type: AnalyticsType, name?: (Scalars['String'] | null), event?: (Scalars['String'] | null), properties?: (Scalars['JSON'] | null)} })
createPageLayoutWidget?: (PageLayoutWidgetGenqlSelection & { __args: {input: CreatePageLayoutWidgetInput} })
@@ -5815,12 +5824,6 @@ export interface MutationGenqlSelection{
createOneAgent?: (AgentGenqlSelection & { __args: {input: CreateAgentInput} })
updateOneAgent?: (AgentGenqlSelection & { __args: {input: UpdateAgentInput} })
deleteOneAgent?: (AgentGenqlSelection & { __args: {input: AgentIdInput} })
uploadAIChatFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })
uploadWorkflowFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })
uploadWorkspaceLogo?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })
uploadWorkspaceMemberProfilePicture?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} })
uploadFilesFieldFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload'], fieldMetadataId: Scalars['String']} })
uploadFilesFieldFileByUniversalIdentifier?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload'], fieldMetadataUniversalIdentifier: Scalars['String']} })
checkoutSession?: (BillingSessionGenqlSelection & { __args: {recurringInterval: SubscriptionInterval, plan: BillingPlanKey, requirePaymentMethod: Scalars['Boolean'], successUrlPath?: (Scalars['String'] | null)} })
switchSubscriptionInterval?: BillingUpdateGenqlSelection
switchBillingPlan?: BillingUpdateGenqlSelection
@@ -5831,13 +5834,6 @@ export interface MutationGenqlSelection{
cancelSwitchMeteredPrice?: BillingUpdateGenqlSelection
refreshEnterpriseValidityToken?: boolean | number
setEnterpriseKey?: (EnterpriseLicenseInfoDTOGenqlSelection & { __args: {enterpriseKey: Scalars['String']} })
createNavigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {input: CreateNavigationMenuItemInput} })
updateNavigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {input: UpdateOneNavigationMenuItemInput} })
deleteNavigationMenuItem?: (NavigationMenuItemGenqlSelection & { __args: {id: Scalars['UUID']} })
createApiKey?: (ApiKeyGenqlSelection & { __args: {input: CreateApiKeyInput} })
updateApiKey?: (ApiKeyGenqlSelection & { __args: {input: UpdateApiKeyInput} })
revokeApiKey?: (ApiKeyGenqlSelection & { __args: {input: RevokeApiKeyInput} })
assignRoleToApiKey?: { __args: {apiKeyId: Scalars['UUID'], roleId: Scalars['UUID']} }
updateWorkspaceMemberRole?: (WorkspaceMemberGenqlSelection & { __args: {workspaceMemberId: Scalars['UUID'], roleId: Scalars['UUID']} })
createOneRole?: (RoleGenqlSelection & { __args: {createRoleInput: CreateRoleInput} })
updateOneRole?: (RoleGenqlSelection & { __args: {updateRoleInput: UpdateRoleInput} })
@@ -5856,6 +5852,10 @@ export interface MutationGenqlSelection{
createApprovedAccessDomain?: (ApprovedAccessDomainGenqlSelection & { __args: {input: CreateApprovedAccessDomainInput} })
deleteApprovedAccessDomain?: { __args: {input: DeleteApprovedAccessDomainInput} }
validateApprovedAccessDomain?: (ApprovedAccessDomainGenqlSelection & { __args: {input: ValidateApprovedAccessDomainInput} })
createApiKey?: (ApiKeyGenqlSelection & { __args: {input: CreateApiKeyInput} })
updateApiKey?: (ApiKeyGenqlSelection & { __args: {input: UpdateApiKeyInput} })
revokeApiKey?: (ApiKeyGenqlSelection & { __args: {input: RevokeApiKeyInput} })
assignRoleToApiKey?: { __args: {apiKeyId: Scalars['UUID'], roleId: Scalars['UUID']} }
createOneField?: (FieldGenqlSelection & { __args: {input: CreateOneFieldMetadataInput} })
updateOneField?: (FieldGenqlSelection & { __args: {input: UpdateOneFieldMetadataInput} })
deleteOneField?: (FieldGenqlSelection & { __args: {input: DeleteOneFieldInput} })
@@ -5965,6 +5965,16 @@ export interface AddQuerySubscriptionInput {eventStreamId: Scalars['String'],que
export interface RemoveQueryFromEventStreamInput {eventStreamId: Scalars['String'],queryId: Scalars['String']}
export interface CreateNavigationMenuItemInput {id?: (Scalars['UUID'] | null),userWorkspaceId?: (Scalars['UUID'] | null),targetRecordId?: (Scalars['UUID'] | null),targetObjectMetadataId?: (Scalars['UUID'] | null),viewId?: (Scalars['UUID'] | null),type: NavigationMenuItemType,name?: (Scalars['String'] | null),link?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),color?: (Scalars['String'] | null),folderId?: (Scalars['UUID'] | null),position?: (Scalars['Float'] | null)}
export interface UpdateOneNavigationMenuItemInput {
/** The id of the record to update */
id: Scalars['UUID'],
/** The record to update */
update: UpdateNavigationMenuItemInput}
export interface UpdateNavigationMenuItemInput {folderId?: (Scalars['UUID'] | null),position?: (Scalars['Float'] | null),name?: (Scalars['String'] | null),link?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),color?: (Scalars['String'] | null)}
export interface CreatePageLayoutWidgetInput {pageLayoutTabId: Scalars['UUID'],title: Scalars['String'],type: WidgetType,objectMetadataId?: (Scalars['UUID'] | null),gridPosition: GridPositionInput,position?: (Scalars['JSON'] | null),configuration: Scalars['JSON']}
export interface GridPositionInput {row: Scalars['Float'],column: Scalars['Float'],rowSpan: Scalars['Float'],columnSpan: Scalars['Float']}
@@ -6107,22 +6117,6 @@ export interface CreateAgentInput {name?: (Scalars['String'] | null),label: Scal
export interface UpdateAgentInput {id: Scalars['UUID'],name?: (Scalars['String'] | null),label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),prompt?: (Scalars['String'] | null),modelId?: (Scalars['String'] | null),roleId?: (Scalars['UUID'] | null),responseFormat?: (Scalars['JSON'] | null),modelConfiguration?: (Scalars['JSON'] | null),evaluationInputs?: (Scalars['String'][] | null)}
export interface CreateNavigationMenuItemInput {userWorkspaceId?: (Scalars['UUID'] | null),targetRecordId?: (Scalars['UUID'] | null),targetObjectMetadataId?: (Scalars['UUID'] | null),viewId?: (Scalars['UUID'] | null),type: NavigationMenuItemType,name?: (Scalars['String'] | null),link?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),color?: (Scalars['String'] | null),folderId?: (Scalars['UUID'] | null),position?: (Scalars['Float'] | null)}
export interface UpdateOneNavigationMenuItemInput {
/** The id of the record to update */
id: Scalars['UUID'],
/** The record to update */
update: UpdateNavigationMenuItemInput}
export interface UpdateNavigationMenuItemInput {folderId?: (Scalars['UUID'] | null),position?: (Scalars['Float'] | null),name?: (Scalars['String'] | null),link?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),color?: (Scalars['String'] | null)}
export interface CreateApiKeyInput {name: Scalars['String'],expiresAt: Scalars['String'],revokedAt?: (Scalars['String'] | null),roleId: Scalars['UUID']}
export interface UpdateApiKeyInput {id: Scalars['UUID'],name?: (Scalars['String'] | null),expiresAt?: (Scalars['String'] | null),revokedAt?: (Scalars['String'] | null)}
export interface RevokeApiKeyInput {id: Scalars['UUID']}
export interface CreateRoleInput {id?: (Scalars['String'] | null),label: Scalars['String'],description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),canUpdateAllSettings?: (Scalars['Boolean'] | null),canAccessAllTools?: (Scalars['Boolean'] | null),canReadAllObjectRecords?: (Scalars['Boolean'] | null),canUpdateAllObjectRecords?: (Scalars['Boolean'] | null),canSoftDeleteAllObjectRecords?: (Scalars['Boolean'] | null),canDestroyAllObjectRecords?: (Scalars['Boolean'] | null),canBeAssignedToUsers?: (Scalars['Boolean'] | null),canBeAssignedToAgents?: (Scalars['Boolean'] | null),canBeAssignedToApiKeys?: (Scalars['Boolean'] | null)}
export interface UpdateRoleInput {update: UpdateRolePayload,
@@ -6153,6 +6147,12 @@ export interface DeleteApprovedAccessDomainInput {id: Scalars['UUID']}
export interface ValidateApprovedAccessDomainInput {validationToken: Scalars['String'],approvedAccessDomainId: Scalars['UUID']}
export interface CreateApiKeyInput {name: Scalars['String'],expiresAt: Scalars['String'],revokedAt?: (Scalars['String'] | null),roleId: Scalars['UUID']}
export interface UpdateApiKeyInput {id: Scalars['UUID'],name?: (Scalars['String'] | null),expiresAt?: (Scalars['String'] | null),revokedAt?: (Scalars['String'] | null)}
export interface RevokeApiKeyInput {id: Scalars['UUID']}
export interface CreateOneFieldMetadataInput {
/** The record to create */
field: CreateFieldInput}
@@ -6824,6 +6824,30 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const FileWithSignedUrl_possibleTypes: string[] = ['FileWithSignedUrl']
export const isFileWithSignedUrl = (obj?: { __typename?: any } | null): obj is FileWithSignedUrl => {
if (!obj?.__typename) throw new Error('__typename is missing in "isFileWithSignedUrl"')
return FileWithSignedUrl_possibleTypes.includes(obj.__typename)
}
const RecordIdentifier_possibleTypes: string[] = ['RecordIdentifier']
export const isRecordIdentifier = (obj?: { __typename?: any } | null): obj is RecordIdentifier => {
if (!obj?.__typename) throw new Error('__typename is missing in "isRecordIdentifier"')
return RecordIdentifier_possibleTypes.includes(obj.__typename)
}
const NavigationMenuItem_possibleTypes: string[] = ['NavigationMenuItem']
export const isNavigationMenuItem = (obj?: { __typename?: any } | null): obj is NavigationMenuItem => {
if (!obj?.__typename) throw new Error('__typename is missing in "isNavigationMenuItem"')
return NavigationMenuItem_possibleTypes.includes(obj.__typename)
}
const ObjectRecordEventProperties_possibleTypes: string[] = ['ObjectRecordEventProperties']
export const isObjectRecordEventProperties = (obj?: { __typename?: any } | null): obj is ObjectRecordEventProperties => {
if (!obj?.__typename) throw new Error('__typename is missing in "isObjectRecordEventProperties"')
@@ -7040,14 +7064,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const FileWithSignedUrl_possibleTypes: string[] = ['FileWithSignedUrl']
export const isFileWithSignedUrl = (obj?: { __typename?: any } | null): obj is FileWithSignedUrl => {
if (!obj?.__typename) throw new Error('__typename is missing in "isFileWithSignedUrl"')
return FileWithSignedUrl_possibleTypes.includes(obj.__typename)
}
const WorkspaceInvitation_possibleTypes: string[] = ['WorkspaceInvitation']
export const isWorkspaceInvitation = (obj?: { __typename?: any } | null): obj is WorkspaceInvitation => {
if (!obj?.__typename) throw new Error('__typename is missing in "isWorkspaceInvitation"')
@@ -7536,22 +7552,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const RecordIdentifier_possibleTypes: string[] = ['RecordIdentifier']
export const isRecordIdentifier = (obj?: { __typename?: any } | null): obj is RecordIdentifier => {
if (!obj?.__typename) throw new Error('__typename is missing in "isRecordIdentifier"')
return RecordIdentifier_possibleTypes.includes(obj.__typename)
}
const NavigationMenuItem_possibleTypes: string[] = ['NavigationMenuItem']
export const isNavigationMenuItem = (obj?: { __typename?: any } | null): obj is NavigationMenuItem => {
if (!obj?.__typename) throw new Error('__typename is missing in "isNavigationMenuItem"')
return NavigationMenuItem_possibleTypes.includes(obj.__typename)
}
const LogicFunctionExecutionResult_possibleTypes: string[] = ['LogicFunctionExecutionResult']
export const isLogicFunctionExecutionResult = (obj?: { __typename?: any } | null): obj is LogicFunctionExecutionResult => {
if (!obj?.__typename) throw new Error('__typename is missing in "isLogicFunctionExecutionResult"')
@@ -8593,6 +8593,14 @@ export const enumPageLayoutType = {
DASHBOARD: 'DASHBOARD' as const
}
export const enumNavigationMenuItemType = {
VIEW: 'VIEW' as const,
FOLDER: 'FOLDER' as const,
LINK: 'LINK' as const,
OBJECT: 'OBJECT' as const,
RECORD: 'RECORD' as const
}
export const enumMetadataEventAction = {
CREATED: 'CREATED' as const,
UPDATED: 'UPDATED' as const,
@@ -8685,14 +8693,6 @@ export const enumRelationType = {
MANY_TO_ONE: 'MANY_TO_ONE' as const
}
export const enumNavigationMenuItemType = {
VIEW: 'VIEW' as const,
FOLDER: 'FOLDER' as const,
LINK: 'LINK' as const,
OBJECT: 'OBJECT' as const,
RECORD: 'RECORD' as const
}
export const enumLogicFunctionExecutionStatus = {
IDLE: 'IDLE' as const,
SUCCESS: 'SUCCESS' as const,
File diff suppressed because it is too large Load Diff
@@ -25,7 +25,7 @@ export const fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate =
AllFlatEntityMaps,
'flatObjectMetadataMaps' | 'flatViewMaps'
>): FlatNavigationMenuItem => {
const id = uuidv4();
const id = createNavigationMenuItemInput.id ?? uuidv4();
const now = new Date().toISOString();
let position = createNavigationMenuItemInput.position;
@@ -13,6 +13,11 @@ import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-m
@InputType()
export class CreateNavigationMenuItemInput {
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
id?: string;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
@@ -1,6 +1,5 @@
import { Module } from '@nestjs/common';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
@@ -12,8 +11,8 @@ import { NavigationMenuItemResolver } from 'src/engine/metadata-modules/navigati
import { NavigationMenuItemService } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.service';
import { NavigationMenuItemAccessService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-access.service';
import { NavigationMenuItemDeletionService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-deletion.service';
import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@@ -25,8 +24,6 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
FlatNavigationMenuItemModule,
PermissionsModule,
FileModule,
UserRoleModule,
ApiKeyModule,
],
providers: [
NavigationMenuItemService,
@@ -35,9 +32,13 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
NavigationMenuItemDeletionListener,
NavigationMenuItemDeletionJob,
NavigationMenuItemResolver,
NavigationMenuItemRecordIdentifierService,
NavigationMenuItemGraphqlApiExceptionInterceptor,
WorkspaceMigrationGraphqlApiExceptionInterceptor,
],
exports: [NavigationMenuItemService],
exports: [
NavigationMenuItemService,
NavigationMenuItemRecordIdentifierService,
],
})
export class NavigationMenuItemModule {}
@@ -2,15 +2,8 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { isApiKeyAuthContext } from 'src/engine/core-modules/auth/guards/is-api-key-auth-context.guard';
import { isApplicationAuthContext } from 'src/engine/core-modules/auth/guards/is-application-auth-context.guard';
import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util';
import { getRecordImageIdentifier } from 'src/engine/core-modules/record-crud/utils/get-record-image-identifier.util';
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';
@@ -27,12 +20,8 @@ import {
NavigationMenuItemExceptionCode,
} from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.exception';
import { NavigationMenuItemAccessService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-access.service';
import { getMinimalSelectForRecordIdentifier } from 'src/engine/metadata-modules/navigation-menu-item/utils/get-minimal-select-for-record-identifier.util';
import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service';
import { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
@@ -43,10 +32,7 @@ export class NavigationMenuItemService {
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly applicationService: ApplicationService,
private readonly navigationMenuItemAccessService: NavigationMenuItemAccessService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly fileService: FileService,
private readonly userRoleService: UserRoleService,
private readonly apiKeyRoleService: ApiKeyRoleService,
private readonly navigationMenuItemRecordIdentifierService: NavigationMenuItemRecordIdentifierService,
) {}
async findAll({
@@ -379,41 +365,6 @@ export class NavigationMenuItemService {
);
}
private async getRoleId(
authContext: WorkspaceAuthContext,
workspaceId: string,
): Promise<string | undefined> {
if (isApiKeyAuthContext(authContext)) {
return this.apiKeyRoleService.getRoleIdForApiKeyId(
authContext.apiKey.id,
workspaceId,
);
}
if (
isApplicationAuthContext(authContext) &&
isDefined(authContext.application.defaultRoleId)
) {
return authContext.application.defaultRoleId;
}
if (isUserAuthContext(authContext)) {
try {
return await this.userRoleService.getRoleIdForUserWorkspace({
userWorkspaceId: authContext.userWorkspaceId,
workspaceId,
});
} catch (error: unknown) {
if (error instanceof PermissionsException) {
return undefined;
}
throw error;
}
}
return undefined;
}
async findTargetRecord({
targetRecordId,
targetObjectMetadataId,
@@ -425,104 +376,15 @@ export class NavigationMenuItemService {
workspaceId: string;
authContext: WorkspaceAuthContext;
}): Promise<RecordIdentifierDTO | null> {
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
try {
return await this.navigationMenuItemRecordIdentifierService.resolveRecordIdentifier(
{
targetRecordId,
targetObjectMetadataId,
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
authContext,
},
);
const objectMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: targetObjectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
if (!isDefined(objectMetadata)) {
return null;
}
try {
const roleId = await this.getRoleId(authContext, workspaceId);
if (!isDefined(roleId)) {
return null;
}
const rolePermissionConfig: RolePermissionConfig = {
unionOf: [roleId],
};
const minimalSelectColumns = getMinimalSelectForRecordIdentifier({
flatObjectMetadata: objectMetadata,
flatFieldMetadataMaps,
});
const record =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const repository =
await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
objectMetadata.nameSingular,
rolePermissionConfig,
);
const alias = objectMetadata.nameSingular;
const queryBuilder = repository.createQueryBuilder(alias);
queryBuilder.select([]);
for (const column of minimalSelectColumns) {
queryBuilder.addSelect(`"${alias}"."${column}"`, column);
}
const rawResult = await queryBuilder
.where(`${alias}.id = :id`, { id: targetRecordId })
.getRawOne();
if (!isDefined(rawResult)) {
return null;
}
const formattedRecord = formatResult<Record<string, unknown>>(
rawResult,
objectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
return formattedRecord;
},
authContext,
);
if (!isDefined(record)) {
return null;
}
const labelIdentifier = getRecordDisplayName(
record,
objectMetadata,
flatFieldMetadataMaps,
);
const imageIdentifier = getRecordImageIdentifier({
record,
flatObjectMetadata: objectMetadata,
flatFieldMetadataMaps,
signUrl: (url: string) =>
this.fileService.signFileUrl({
url,
workspaceId,
}),
});
return {
id: record.id as string,
labelIdentifier,
imageIdentifier,
};
} catch (error: unknown) {
if (error instanceof PermissionsException) {
return null;
@@ -0,0 +1,134 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util';
import { getRecordImageIdentifier } from 'src/engine/core-modules/record-crud/utils/get-record-image-identifier.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { RecordIdentifierDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/record-identifier.dto';
import { getMinimalSelectForRecordIdentifier } from 'src/engine/metadata-modules/navigation-menu-item/utils/get-minimal-select-for-record-identifier.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
@Injectable()
export class NavigationMenuItemRecordIdentifierService {
constructor(
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly fileService: FileService,
private readonly permissionsService: PermissionsService,
) {}
async resolveRecordIdentifier({
targetRecordId,
targetObjectMetadataId,
workspaceId,
authContext,
}: {
targetRecordId: string;
targetObjectMetadataId: string;
workspaceId: string;
authContext?: WorkspaceAuthContext;
}): Promise<RecordIdentifierDTO | null> {
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const objectMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: targetObjectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
if (!isDefined(objectMetadata)) {
return null;
}
const minimalSelectColumns = getMinimalSelectForRecordIdentifier({
flatObjectMetadata: objectMetadata,
flatFieldMetadataMaps,
});
const resolvedAuthContext: WorkspaceAuthContext =
authContext ??
({
type: 'system',
workspace: { id: workspaceId },
} as WorkspaceAuthContext);
const rolePermissionConfig =
await this.permissionsService.resolveRolePermissionConfigFromAuthContext(
resolvedAuthContext,
);
const record =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const repository = await this.globalWorkspaceOrmManager.getRepository(
workspaceId,
objectMetadata.nameSingular,
rolePermissionConfig ?? { shouldBypassPermissionChecks: true },
);
const alias = objectMetadata.nameSingular;
const queryBuilder = repository.createQueryBuilder(alias);
queryBuilder.select([]);
for (const column of minimalSelectColumns) {
queryBuilder.addSelect(`"${alias}"."${column}"`, column);
}
const rawResult = await queryBuilder
.where(`${alias}.id = :id`, { id: targetRecordId })
.getRawOne();
if (!isDefined(rawResult)) {
return null;
}
return formatResult<Record<string, unknown>>(
rawResult,
objectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
},
resolvedAuthContext,
);
if (!isDefined(record)) {
return null;
}
const labelIdentifier = getRecordDisplayName(
record,
objectMetadata,
flatFieldMetadataMaps,
);
const imageIdentifier = getRecordImageIdentifier({
record,
flatObjectMetadata: objectMetadata,
flatFieldMetadataMaps,
signUrl: (url: string) =>
this.fileService.signFileUrl({
url,
workspaceId,
}),
});
return {
id: record.id as string,
labelIdentifier,
imageIdentifier,
};
}
}
@@ -12,6 +12,11 @@ import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { isApiKeyAuthContext } from 'src/engine/core-modules/auth/guards/is-api-key-auth-context.guard';
import { isApplicationAuthContext } from 'src/engine/core-modules/auth/guards/is-application-auth-context.guard';
import { isSystemAuthContext } from 'src/engine/core-modules/auth/guards/is-system-auth-context.guard';
import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { TOOL_PERMISSION_FLAGS } from 'src/engine/metadata-modules/permissions/constants/tool-permission-flags';
import {
PermissionsException,
@@ -129,6 +134,48 @@ export class PermissionsService {
objectsPermissions: {},
}) as const satisfies UserWorkspacePermissions;
// TODO: this could likely be handled in the ORM layer
public async resolveRolePermissionConfigFromAuthContext(
authContext: WorkspaceAuthContext,
): Promise<RolePermissionConfig | null> {
const workspaceId = authContext.workspace.id;
if (isSystemAuthContext(authContext)) {
return { shouldBypassPermissionChecks: true };
}
if (isApiKeyAuthContext(authContext)) {
const roleId = await this.apiKeyRoleService.getRoleIdForApiKeyId(
authContext.apiKey.id,
workspaceId,
);
return { intersectionOf: [roleId] };
}
if (
isApplicationAuthContext(authContext) &&
isDefined(authContext.application.defaultRoleId)
) {
return { intersectionOf: [authContext.application.defaultRoleId] };
}
if (isUserAuthContext(authContext)) {
const roleId = await this.userRoleService.getRoleIdForUserWorkspace({
userWorkspaceId: authContext.userWorkspaceId,
workspaceId,
});
if (!isDefined(roleId)) {
return null;
}
return { intersectionOf: [roleId] };
}
return null;
}
public async userHasWorkspaceSettingPermission({
userWorkspaceId,
workspaceId,
@@ -22,6 +22,7 @@ import { type EventStreamData } from 'src/engine/subscriptions/types/event-strea
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { type WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service';
import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter/workspace-event-emitter.service';
jest.mock(
@@ -301,6 +302,14 @@ describe('WorkspaceEventEmitterService', () => {
provide: CommonSelectFieldsHelper,
useValue: new CommonSelectFieldsHelper(),
},
{
provide: NavigationMenuItemRecordIdentifierService,
useValue: {
enrichNavigationMenuItemEventsWithRecordIdentifiers: jest
.fn()
.mockResolvedValue(undefined),
},
},
],
}).compile();
@@ -4,6 +4,7 @@ import { ProcessNestedRelationsV2Helper } from 'src/engine/api/common/common-nes
import { ProcessNestedRelationsHelper } from 'src/engine/api/common/common-nested-relations-processor/process-nested-relations.helper';
import { CommonSelectFieldsHelper } from 'src/engine/api/common/common-select-fields/common-select-fields-helper';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
@@ -16,6 +17,7 @@ import { WorkspaceEventEmitterService } from 'src/engine/workspace-event-emitter
SubscriptionsModule,
WorkspaceCacheModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
NavigationMenuItemModule,
],
providers: [
WorkspaceEventEmitter,
@@ -31,6 +31,7 @@ import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/typ
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service';
import { enrichFieldMetadataEventWithRelations } from 'src/engine/workspace-event-emitter/utils/enrich-field-metadata-event-with-relations.util';
import { UserWorkspaceRoleMap } from 'src/engine/metadata-modules/role-target/types/user-workspace-role-map';
import { type FlatRowLevelPermissionPredicateGroupMaps } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-group-maps.type';
@@ -62,6 +63,7 @@ export class WorkspaceEventEmitterService {
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly commonSelectFieldsHelper: CommonSelectFieldsHelper,
private readonly navigationMenuItemRecordIdentifierService: NavigationMenuItemRecordIdentifierService,
) {}
async publish(
@@ -134,6 +136,8 @@ export class WorkspaceEventEmitterService {
const enrichedMetadataEventBatch = isMetadata
? await this.enrichFieldMetadataEventsWithRelations(
eventBatch as MetadataEventBatch,
).then((batch) =>
this.enrichNavigationMenuItemEventsWithTargetRecordIdentifier(batch),
)
: undefined;
@@ -251,6 +255,59 @@ export class WorkspaceEventEmitterService {
return { ...metadataEventBatch, events: enrichedEvents };
}
private async enrichNavigationMenuItemEventsWithTargetRecordIdentifier(
metadataEventBatch: MetadataEventBatch,
): Promise<MetadataEventBatch> {
if (metadataEventBatch.metadataName !== 'navigationMenuItem') {
return metadataEventBatch;
}
const enrichedEvents = await Promise.all(
metadataEventBatch.events.map(async (event) => {
if (
!('after' in event.properties) ||
!isDefined(event.properties.after)
) {
return event;
}
const after = event.properties.after as Record<string, unknown>;
const targetRecordId = after.targetRecordId as string | undefined;
const targetObjectMetadataId = after.targetObjectMetadataId as
| string
| undefined;
if (!isDefined(targetRecordId) || !isDefined(targetObjectMetadataId)) {
return event;
}
const targetRecordIdentifier =
await this.navigationMenuItemRecordIdentifierService.resolveRecordIdentifier(
{
targetRecordId,
targetObjectMetadataId,
workspaceId: metadataEventBatch.workspaceId,
},
);
const enrichedAfter: Record<string, unknown> = {
...after,
targetRecordIdentifier,
};
return {
...event,
properties: {
...event.properties,
after: enrichedAfter,
},
} as typeof event;
}),
);
return { ...metadataEventBatch, events: enrichedEvents };
}
private async processObjectRecordStreamEvents(
streamChannelId: string,
streamData: EventStreamData,