Fix favorite showing non-readable objects (#22217)

## Context
Navigation menu items backed by objects the user has no read permission
on were correctly hidden from the Workspace section, but Favorites still
showed them. Favorites are user-scoped nav items (tied to
workspaceMemberId), and FavoritesSection only filtered out folder
children (!item.folderId) without ever checking canReadObjectRecords.

The shared upstream filter (filterAndSortNavigationMenuItems)
intentionally does not apply read permissions, because its output also
drives drag-and-drop position math and layout-customization/edit mode,
which need the complete, unfiltered list. So read-permission filtering
belongs at the display layer.

## Fix
New shared hook useReadableNavigationMenuItems that centralizes the
read-permission filtering logic previously duplicated across sections:
wires up objectMetadataItems + views + object permissions around
isNavigationMenuItemReadable
filters folder children and top-level items (dropping folders whose
children are all unreadable)
exposes both raw filtered* outputs and
isLayoutCustomizationModeEnabled-aware display* outputs
FavoritesSection now applies the filter via the hook, so unreadable
favorites are hidden — while still showing everything in
layout-customization mode (consistent with the Workspace section).
WorkspaceSectionContainer refactored to consume the same hook, removing
its inline isItemReadable, the dual-map reduce, and inline filtering.

## Before
### With access
<img width="842" height="570" alt="Screenshot 2026-06-25 at 14 01 38"
src="https://github.com/user-attachments/assets/ae51f3c8-c178-4162-84ce-3fe49cf07987"
/>

### Without access
<img width="987" height="590" alt="Screenshot 2026-06-25 at 14 02 08"
src="https://github.com/user-attachments/assets/94dacea3-bd77-4fcb-868d-353ed513b28c"
/>

## After
### Without access
<img width="1001" height="615" alt="Screenshot 2026-06-25 at 14 02 46"
src="https://github.com/user-attachments/assets/e1ccd5d9-8583-4ff1-ab91-6f6d187925c5"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22217?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Weiko
2026-06-26 13:55:09 +02:00
committed by GitHub
parent fdf9f543ae
commit ebe067f65c
3 changed files with 83 additions and 36 deletions
@@ -0,0 +1,60 @@
import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/isNavigationMenuItemFolder';
import { isNavigationMenuItemReadable } from '@/navigation-menu-item/common/utils/isNavigationMenuItemReadable';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
type UseReadableNavigationMenuItemsArgs = {
topLevelItems: NavigationMenuItem[];
folderChildrenById: Map<string, NavigationMenuItem[]>;
};
export const useReadableNavigationMenuItems = ({
topLevelItems,
folderChildrenById,
}: UseReadableNavigationMenuItemsArgs) => {
const isLayoutCustomizationModeEnabled = useAtomStateValue(
isLayoutCustomizationModeEnabledState,
);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const views = useAtomStateValue(viewsSelector);
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const isItemReadable = (item: NavigationMenuItem) =>
isNavigationMenuItemReadable({
item,
objectMetadataItems,
views,
objectPermissionsByObjectMetadataId,
});
const filteredFolderChildrenById = new Map<string, NavigationMenuItem[]>();
for (const [folderId, children] of folderChildrenById) {
filteredFolderChildrenById.set(folderId, children.filter(isItemReadable));
}
const filteredTopLevelItems = topLevelItems.filter((item) =>
isNavigationMenuItemFolder(item)
? (filteredFolderChildrenById.get(item.id) ?? []).length > 0
: isItemReadable(item),
);
const displayTopLevelItems = isLayoutCustomizationModeEnabled
? topLevelItems
: filteredTopLevelItems;
const displayFolderChildrenById = isLayoutCustomizationModeEnabled
? folderChildrenById
: filteredFolderChildrenById;
return {
isItemReadable,
filteredTopLevelItems,
filteredFolderChildrenById,
displayTopLevelItems,
displayFolderChildrenById,
};
};
@@ -20,6 +20,7 @@ import { NavigationMenuItemDroppableSlot } from '@/navigation-menu-item/display/
import { NavigationMenuItemSortableItem } from '@/navigation-menu-item/display/dnd/components/NavigationMenuItemSortableItem';
import { useIsDropDisabledForSection } from '@/navigation-menu-item/display/dnd/hooks/useIsDropDisabledForSection';
import { useNavigationMenuItemsByFolder } from '@/navigation-menu-item/display/folder/hooks/useNavigationMenuItemsByFolder';
import { useReadableNavigationMenuItems } from '@/navigation-menu-item/display/hooks/useReadableNavigationMenuItems';
import { useSortedNavigationMenuItems } from '@/navigation-menu-item/display/hooks/useSortedNavigationMenuItems';
import { NavigationMenuItemOrphanDropTarget } from '@/navigation-menu-item/display/sections/components/NavigationMenuItemOrphanDropTarget';
import { NavigationMenuItemSection } from '@/navigation-menu-item/display/sections/components/NavigationMenuItemSection';
@@ -71,12 +72,12 @@ export const FavoritesSection = () => {
'Favorites',
);
const topLevelItems = useMemo(
const allTopLevelItems = useMemo(
() => navigationMenuItemsSorted.filter((item) => !item.folderId),
[navigationMenuItemsSorted],
);
const folderChildrenById = useMemo(() => {
const allFolderChildrenById = useMemo(() => {
const map = new Map<string, NavigationMenuItem[]>();
for (const folder of userNavigationMenuItemsByFolder) {
map.set(folder.id, folder.navigationMenuItems);
@@ -84,6 +85,14 @@ export const FavoritesSection = () => {
return map;
}, [userNavigationMenuItemsByFolder]);
const {
displayTopLevelItems: topLevelItems,
displayFolderChildrenById: folderChildrenById,
} = useReadableNavigationMenuItems({
topLevelItems: allTopLevelItems,
folderChildrenById: allFolderChildrenById,
});
const folderCount = useMemo(
() => topLevelItems.filter(isNavigationMenuItemFolder).length,
[topLevelItems],
@@ -7,9 +7,8 @@ import { type NavigationMenuItem } from '~/generated-metadata/graphql';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { NavigationMenuItemDroppableIds } from '@/navigation-menu-item/common/constants/NavigationMenuItemDroppableIds';
import { NavigationDropTargetContext } from '@/navigation-menu-item/common/contexts/NavigationDropTargetContext';
import { isNavigationMenuItemFolder } from '@/navigation-menu-item/common/utils/isNavigationMenuItemFolder';
import { isNavigationMenuItemReadable } from '@/navigation-menu-item/common/utils/isNavigationMenuItemReadable';
import { type NavigationMenuItemClickParams } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemSectionItems';
import { useReadableNavigationMenuItems } from '@/navigation-menu-item/display/hooks/useReadableNavigationMenuItems';
import { getObjectMetadataForNavigationMenuItem } from '@/navigation-menu-item/display/object/utils/getObjectMetadataForNavigationMenuItem';
import { NavigationMenuItemSection } from '@/navigation-menu-item/display/sections/components/NavigationMenuItemSection';
import { WorkspaceSectionListReadOnly } from '@/navigation-menu-item/display/sections/workspace/components/WorkspaceSectionListReadOnly';
@@ -17,7 +16,6 @@ import type { EditModeProps } from '@/object-metadata/components/EditModeProps';
import { WorkspaceSectionListEditModeFallback } from '@/object-metadata/components/WorkspaceSectionListEditModeFallback';
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { viewsSelector } from '@/views/states/selectors/viewsSelector';
@@ -59,7 +57,6 @@ export const WorkspaceSectionContainer = ({
useNavigationSection('Workspace');
const views = useAtomStateValue(viewsSelector);
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
const { addToNavigationFallbackDestination } = useContext(
NavigationDropTargetContext,
@@ -69,46 +66,27 @@ export const WorkspaceSectionContainer = ({
const isAddToNavigationDropTargetVisible =
addToNavigationFallbackDestination?.droppableId ===
NavigationMenuItemDroppableIds.WORKSPACE_ORPHAN_NAVIGATION_MENU_ITEMS;
const isItemReadable = (item: NavigationMenuItem) =>
isNavigationMenuItemReadable({
item,
objectMetadataItems,
views,
objectPermissionsByObjectMetadataId,
});
const { folderChildrenById, filteredFolderChildrenById } = items.reduce<{
folderChildrenById: Map<string, NavigationMenuItem[]>;
filteredFolderChildrenById: Map<string, NavigationMenuItem[]>;
}>(
const folderChildrenById = items.reduce<Map<string, NavigationMenuItem[]>>(
(acc, item) => {
const folderId = item.folderId;
if (isDefined(folderId)) {
const children = acc.folderChildrenById.get(folderId) ?? [];
const children = acc.get(folderId) ?? [];
children.push(item);
acc.folderChildrenById.set(folderId, children);
if (isItemReadable(item)) {
const readableChildren =
acc.filteredFolderChildrenById.get(folderId) ?? [];
readableChildren.push(item);
acc.filteredFolderChildrenById.set(folderId, readableChildren);
}
acc.set(folderId, children);
}
return acc;
},
{
folderChildrenById: new Map(),
filteredFolderChildrenById: new Map(),
},
new Map(),
);
const filteredItems = flatItems.filter((item) => {
if (isNavigationMenuItemFolder(item)) {
return (filteredFolderChildrenById.get(item.id) ?? []).length > 0;
}
return isItemReadable(item);
});
const { filteredTopLevelItems, filteredFolderChildrenById } =
useReadableNavigationMenuItems({
topLevelItems: flatItems,
folderChildrenById,
});
const filteredItems = filteredTopLevelItems;
const workspaceOrphanItemsForSection = isLayoutCustomizationModeEnabled
? flatItems