Collapsible menu (#5846)

A mini PR to discuss with @Bonapara tomorrow

Separating remote objects from others and making the menu collapsible
(style to be changed)
<img width="225" alt="Screenshot 2024-06-12 at 23 25 59"
src="https://github.com/twentyhq/twenty/assets/6399865/b4b69d36-6770-43a2-a5e8-bfcdf0a629ea">

Biggest issue is we don't use local storage today so the collapsed state
gets lost.
I see we have localStorageEffect with recoil. Maybe store it there?
Seems easy but don't want to introduce a bad pattern.


Todo:
- style update
- collapsible favorites
- persistent storage
This commit is contained in:
Félix Malfait
2024-06-14 12:35:23 +02:00
committed by GitHub
parent 8d8bf1c128
commit a2e89af6b2
8 changed files with 222 additions and 104 deletions

View File

@ -1,4 +1,5 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { Avatar } from 'twenty-ui'; import { Avatar } from 'twenty-ui';
import { FavoritesSkeletonLoader } from '@/favorites/components/FavoritesSkeletonLoader'; import { FavoritesSkeletonLoader } from '@/favorites/components/FavoritesSkeletonLoader';
@ -8,6 +9,7 @@ import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableLi
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection'; import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle'; import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection';
import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64'; import { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64';
import { useFavorites } from '../hooks/useFavorites'; import { useFavorites } from '../hooks/useFavorites';
@ -36,6 +38,10 @@ export const Favorites = () => {
const { favorites, handleReorderFavorite } = useFavorites(); const { favorites, handleReorderFavorite } = useFavorites();
const loading = useIsPrefetchLoading(); const loading = useIsPrefetchLoading();
const { toggleNavigationSection, isNavigationSectionOpenState } =
useNavigationSection('Favorites');
const isNavigationSectionOpen = useRecoilValue(isNavigationSectionOpenState);
if (loading) { if (loading) {
return <FavoritesSkeletonLoader />; return <FavoritesSkeletonLoader />;
} }
@ -44,48 +50,53 @@ export const Favorites = () => {
return ( return (
<StyledContainer> <StyledContainer>
<NavigationDrawerSectionTitle label="Favorites" /> <NavigationDrawerSectionTitle
<DraggableList label="Favorites"
onDragEnd={handleReorderFavorite} onClick={() => toggleNavigationSection()}
draggableItems={
<>
{favorites.map((favorite, index) => {
const {
id,
labelIdentifier,
avatarUrl,
avatarType,
link,
recordId,
} = favorite;
return (
<DraggableItem
key={id}
draggableId={id}
index={index}
itemComponent={
<StyledNavigationDrawerItem
key={id}
label={labelIdentifier}
Icon={() => (
<StyledAvatar
entityId={recordId}
avatarUrl={getImageAbsoluteURIOrBase64(avatarUrl)}
type={avatarType}
placeholder={labelIdentifier}
className="fav-avatar"
/>
)}
to={link}
/>
}
/>
);
})}
</>
}
/> />
{isNavigationSectionOpen && (
<DraggableList
onDragEnd={handleReorderFavorite}
draggableItems={
<>
{favorites.map((favorite, index) => {
const {
id,
labelIdentifier,
avatarUrl,
avatarType,
link,
recordId,
} = favorite;
return (
<DraggableItem
key={id}
draggableId={id}
index={index}
itemComponent={
<StyledNavigationDrawerItem
key={id}
label={labelIdentifier}
Icon={() => (
<StyledAvatar
entityId={recordId}
avatarUrl={getImageAbsoluteURIOrBase64(avatarUrl)}
type={avatarType}
placeholder={labelIdentifier}
className="fav-avatar"
/>
)}
to={link}
/>
}
/>
);
})}
</>
}
/>
)}
</StyledContainer> </StyledContainer>
); );
}; };

View File

@ -9,7 +9,6 @@ import { Favorites } from '@/favorites/components/Favorites';
import { ObjectMetadataNavItems } from '@/object-metadata/components/ObjectMetadataNavItems'; import { ObjectMetadataNavItems } from '@/object-metadata/components/ObjectMetadataNavItems';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection'; import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
@ -56,10 +55,8 @@ export const MainNavigationDrawerItems = () => {
<Favorites /> <Favorites />
<NavigationDrawerSection> <ObjectMetadataNavItems isRemote={false} />
<NavigationDrawerSectionTitle label="Workspace" /> <ObjectMetadataNavItems isRemote={true} />
<ObjectMetadataNavItems />
</NavigationDrawerSection>
</> </>
); );
}; };

View File

@ -1,4 +1,5 @@
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { useIcons } from 'twenty-ui'; import { useIcons } from 'twenty-ui';
import { ObjectMetadataNavItemsSkeletonLoader } from '@/object-metadata/components/ObjectMetadataNavItemsSkeletonLoader'; import { ObjectMetadataNavItemsSkeletonLoader } from '@/object-metadata/components/ObjectMetadataNavItemsSkeletonLoader';
@ -7,11 +8,21 @@ import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading';
import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData'; import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData';
import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem';
import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection';
import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle';
import { useNavigationSection } from '@/ui/navigation/navigation-drawer/hooks/useNavigationSection';
import { View } from '@/views/types/View'; import { View } from '@/views/types/View';
import { getObjectMetadataItemViews } from '@/views/utils/getObjectMetadataItemViews'; import { getObjectMetadataItemViews } from '@/views/utils/getObjectMetadataItemViews';
export const ObjectMetadataNavItems = () => { export const ObjectMetadataNavItems = ({ isRemote }: { isRemote: boolean }) => {
const { toggleNavigationSection, isNavigationSectionOpenState } =
useNavigationSection('Objects' + (isRemote ? 'Remote' : 'Workspace'));
const isNavigationSectionOpen = useRecoilValue(isNavigationSectionOpenState);
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems(); const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const filteredActiveObjectMetadataItems = activeObjectMetadataItems.filter(
(item) => (isRemote ? item.isRemote : !item.isRemote),
);
const { getIcon } = useIcons(); const { getIcon } = useIcons();
const currentPath = useLocation().pathname; const currentPath = useLocation().pathname;
@ -23,55 +34,69 @@ export const ObjectMetadataNavItems = () => {
} }
return ( return (
<> filteredActiveObjectMetadataItems.length > 0 && (
{[ <NavigationDrawerSection>
...activeObjectMetadataItems <NavigationDrawerSectionTitle
.filter((item) => label={isRemote ? 'Remote' : 'Workspace'}
['person', 'company', 'opportunity'].includes(item.nameSingular), onClick={() => toggleNavigationSection()}
) />
.sort((objectMetadataItemA, objectMetadataItemB) => {
const order = ['person', 'company', 'opportunity'];
const indexA = order.indexOf(objectMetadataItemA.nameSingular);
const indexB = order.indexOf(objectMetadataItemB.nameSingular);
if (indexA === -1 || indexB === -1) {
return objectMetadataItemA.nameSingular.localeCompare(
objectMetadataItemB.nameSingular,
);
}
return indexA - indexB;
}),
...activeObjectMetadataItems
.filter(
(item) =>
!['person', 'company', 'opportunity'].includes(item.nameSingular),
)
.sort((objectMetadataItemA, objectMetadataItemB) => {
return new Date(objectMetadataItemA.createdAt) <
new Date(objectMetadataItemB.createdAt)
? 1
: -1;
}),
].map((objectMetadataItem) => {
const objectMetadataViews = getObjectMetadataItemViews(
objectMetadataItem.id,
views,
);
const viewId = objectMetadataViews[0]?.id;
const navigationPath = `/objects/${objectMetadataItem.namePlural}${ {isNavigationSectionOpen &&
viewId ? `?view=${viewId}` : '' [
}`; ...filteredActiveObjectMetadataItems
.filter((item) =>
['person', 'company', 'opportunity'].includes(
item.nameSingular,
),
)
.sort((objectMetadataItemA, objectMetadataItemB) => {
const order = ['person', 'company', 'opportunity'];
const indexA = order.indexOf(objectMetadataItemA.nameSingular);
const indexB = order.indexOf(objectMetadataItemB.nameSingular);
if (indexA === -1 || indexB === -1) {
return objectMetadataItemA.nameSingular.localeCompare(
objectMetadataItemB.nameSingular,
);
}
return indexA - indexB;
}),
...filteredActiveObjectMetadataItems
.filter(
(item) =>
!['person', 'company', 'opportunity'].includes(
item.nameSingular,
),
)
.sort((objectMetadataItemA, objectMetadataItemB) => {
return new Date(objectMetadataItemA.createdAt) <
new Date(objectMetadataItemB.createdAt)
? 1
: -1;
}),
].map((objectMetadataItem) => {
const objectMetadataViews = getObjectMetadataItemViews(
objectMetadataItem.id,
views,
);
const viewId = objectMetadataViews[0]?.id;
return ( const navigationPath = `/objects/${objectMetadataItem.namePlural}${
<NavigationDrawerItem viewId ? `?view=${viewId}` : ''
key={objectMetadataItem.id} }`;
label={objectMetadataItem.labelPlural}
to={navigationPath} return (
active={currentPath === `/objects/${objectMetadataItem.namePlural}`} <NavigationDrawerItem
Icon={getIcon(objectMetadataItem.icon)} key={objectMetadataItem.id}
/> label={objectMetadataItem.labelPlural}
); to={navigationPath}
})} active={
</> currentPath === `/objects/${objectMetadataItem.namePlural}`
}
Icon={getIcon(objectMetadataItem.icon)}
/>
);
})}
</NavigationDrawerSection>
)
); );
}; };

View File

@ -2,21 +2,34 @@ import styled from '@emotion/styled';
import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading'; import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading';
import { NavigationDrawerSectionTitleSkeletonLoader } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitleSkeletonLoader'; import { NavigationDrawerSectionTitleSkeletonLoader } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitleSkeletonLoader';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
type NavigationDrawerSectionTitleProps = { type NavigationDrawerSectionTitleProps = {
onClick?: () => void;
label: string; label: string;
}; };
const StyledTitle = styled.div` const StyledTitle = styled.div<{ onClick?: () => void }>`
align-items: center;
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.light}; color: ${({ theme }) => theme.font.color.light};
display: flex; display: flex;
font-size: ${({ theme }) => theme.font.size.xs}; font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold}; font-weight: ${({ theme }) => theme.font.weight.semiBold};
height: ${({ theme }) => theme.spacing(4)};
padding: ${({ theme }) => theme.spacing(1)}; padding: ${({ theme }) => theme.spacing(1)};
padding-top: 0;
${({ onClick, theme }) =>
!isUndefinedOrNull(onClick)
? `&:hover {
cursor: pointer;
background-color:${theme.background.transparent.light};
}`
: ''}
`; `;
export const NavigationDrawerSectionTitle = ({ export const NavigationDrawerSectionTitle = ({
onClick,
label, label,
}: NavigationDrawerSectionTitleProps) => { }: NavigationDrawerSectionTitleProps) => {
const loading = useIsPrefetchLoading(); const loading = useIsPrefetchLoading();
@ -24,5 +37,5 @@ export const NavigationDrawerSectionTitle = ({
if (loading) { if (loading) {
return <NavigationDrawerSectionTitleSkeletonLoader />; return <NavigationDrawerSectionTitleSkeletonLoader />;
} }
return <StyledTitle>{label}</StyledTitle>; return <StyledTitle onClick={onClick}>{label}</StyledTitle>;
}; };

View File

@ -0,0 +1,60 @@
import { useRecoilCallback } from 'recoil';
import { isNavigationSectionOpenComponentState } from '@/ui/navigation/navigation-drawer/states/isNavigationSectionOpenComponentState';
import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState';
export const useNavigationSection = (scopeId: string) => {
const closeNavigationSection = useRecoilCallback(
({ set }) =>
() => {
set(
isNavigationSectionOpenComponentState({
scopeId,
}),
false,
);
},
[scopeId],
);
const openNavigationSection = useRecoilCallback(
({ set }) =>
() => {
set(
isNavigationSectionOpenComponentState({
scopeId,
}),
true,
);
},
[scopeId],
);
const toggleNavigationSection = useRecoilCallback(
({ snapshot }) =>
() => {
const isNavigationSectionOpen = snapshot
.getLoadable(isNavigationSectionOpenComponentState({ scopeId }))
.getValue();
if (isNavigationSectionOpen) {
closeNavigationSection();
} else {
openNavigationSection();
}
},
[closeNavigationSection, openNavigationSection, scopeId],
);
const isNavigationSectionOpenState = extractComponentState(
isNavigationSectionOpenComponentState,
scopeId,
);
return {
isNavigationSectionOpenState,
closeNavigationSection,
openNavigationSection,
toggleNavigationSection,
};
};

View File

@ -0,0 +1,9 @@
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { localStorageEffect } from '~/utils/recoil-effects';
export const isNavigationSectionOpenComponentState =
createComponentState<boolean>({
key: 'isNavigationSectionOpenComponentState',
defaultValue: true,
effects: [localStorageEffect()],
});

View File

@ -1,16 +1,19 @@
import { atomFamily } from 'recoil'; import { AtomEffect, atomFamily } from 'recoil';
import { ComponentStateKey } from '@/ui/utilities/state/component-state/types/ComponentStateKey'; import { ComponentStateKey } from '@/ui/utilities/state/component-state/types/ComponentStateKey';
export const createComponentState = <ValueType>({ export const createComponentState = <ValueType>({
key, key,
defaultValue, defaultValue,
effects,
}: { }: {
key: string; key: string;
defaultValue: ValueType; defaultValue: ValueType;
effects?: AtomEffect<ValueType>[];
}) => { }) => {
return atomFamily<ValueType, ComponentStateKey>({ return atomFamily<ValueType, ComponentStateKey>({
key, key,
default: defaultValue, default: defaultValue,
effects: effects,
}); });
}; };

View File

@ -5,17 +5,17 @@ import { cookieStorage } from '~/utils/cookie-storage';
import { isDefined } from './isDefined'; import { isDefined } from './isDefined';
export const localStorageEffect = export const localStorageEffect =
<T>(key: string): AtomEffect<T> => <T>(key?: string): AtomEffect<T> =>
({ setSelf, onSet }) => { ({ setSelf, onSet, node }) => {
const savedValue = localStorage.getItem(key); const savedValue = localStorage.getItem(key ?? node.key);
if (savedValue != null) { if (savedValue != null) {
setSelf(JSON.parse(savedValue)); setSelf(JSON.parse(savedValue));
} }
onSet((newValue, _, isReset) => { onSet((newValue, _, isReset) => {
isReset isReset
? localStorage.removeItem(key) ? localStorage.removeItem(key ?? node.key)
: localStorage.setItem(key, JSON.stringify(newValue)); : localStorage.setItem(key ?? node.key, JSON.stringify(newValue));
}); });
}; };