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:
@ -1,4 +1,5 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Avatar } from 'twenty-ui';
|
||||
|
||||
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 { 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 { getImageAbsoluteURIOrBase64 } from '~/utils/image/getImageAbsoluteURIOrBase64';
|
||||
|
||||
import { useFavorites } from '../hooks/useFavorites';
|
||||
@ -36,6 +38,10 @@ export const Favorites = () => {
|
||||
const { favorites, handleReorderFavorite } = useFavorites();
|
||||
const loading = useIsPrefetchLoading();
|
||||
|
||||
const { toggleNavigationSection, isNavigationSectionOpenState } =
|
||||
useNavigationSection('Favorites');
|
||||
const isNavigationSectionOpen = useRecoilValue(isNavigationSectionOpenState);
|
||||
|
||||
if (loading) {
|
||||
return <FavoritesSkeletonLoader />;
|
||||
}
|
||||
@ -44,48 +50,53 @@ export const Favorites = () => {
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<NavigationDrawerSectionTitle label="Favorites" />
|
||||
<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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
}
|
||||
<NavigationDrawerSectionTitle
|
||||
label="Favorites"
|
||||
onClick={() => toggleNavigationSection()}
|
||||
/>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -9,7 +9,6 @@ import { Favorites } from '@/favorites/components/Favorites';
|
||||
import { ObjectMetadataNavItems } from '@/object-metadata/components/ObjectMetadataNavItems';
|
||||
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 { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
|
||||
@ -56,10 +55,8 @@ export const MainNavigationDrawerItems = () => {
|
||||
|
||||
<Favorites />
|
||||
|
||||
<NavigationDrawerSection>
|
||||
<NavigationDrawerSectionTitle label="Workspace" />
|
||||
<ObjectMetadataNavItems />
|
||||
</NavigationDrawerSection>
|
||||
<ObjectMetadataNavItems isRemote={false} />
|
||||
<ObjectMetadataNavItems isRemote={true} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useIcons } from 'twenty-ui';
|
||||
|
||||
import { ObjectMetadataNavItemsSkeletonLoader } from '@/object-metadata/components/ObjectMetadataNavItemsSkeletonLoader';
|
||||
@ -7,11 +8,21 @@ import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading';
|
||||
import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData';
|
||||
import { PrefetchKey } from '@/prefetch/types/PrefetchKey';
|
||||
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 { 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 filteredActiveObjectMetadataItems = activeObjectMetadataItems.filter(
|
||||
(item) => (isRemote ? item.isRemote : !item.isRemote),
|
||||
);
|
||||
const { getIcon } = useIcons();
|
||||
const currentPath = useLocation().pathname;
|
||||
|
||||
@ -23,55 +34,69 @@ export const ObjectMetadataNavItems = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{[
|
||||
...activeObjectMetadataItems
|
||||
.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;
|
||||
}),
|
||||
...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;
|
||||
filteredActiveObjectMetadataItems.length > 0 && (
|
||||
<NavigationDrawerSection>
|
||||
<NavigationDrawerSectionTitle
|
||||
label={isRemote ? 'Remote' : 'Workspace'}
|
||||
onClick={() => toggleNavigationSection()}
|
||||
/>
|
||||
|
||||
const navigationPath = `/objects/${objectMetadataItem.namePlural}${
|
||||
viewId ? `?view=${viewId}` : ''
|
||||
}`;
|
||||
{isNavigationSectionOpen &&
|
||||
[
|
||||
...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 (
|
||||
<NavigationDrawerItem
|
||||
key={objectMetadataItem.id}
|
||||
label={objectMetadataItem.labelPlural}
|
||||
to={navigationPath}
|
||||
active={currentPath === `/objects/${objectMetadataItem.namePlural}`}
|
||||
Icon={getIcon(objectMetadataItem.icon)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
const navigationPath = `/objects/${objectMetadataItem.namePlural}${
|
||||
viewId ? `?view=${viewId}` : ''
|
||||
}`;
|
||||
|
||||
return (
|
||||
<NavigationDrawerItem
|
||||
key={objectMetadataItem.id}
|
||||
label={objectMetadataItem.labelPlural}
|
||||
to={navigationPath}
|
||||
active={
|
||||
currentPath === `/objects/${objectMetadataItem.namePlural}`
|
||||
}
|
||||
Icon={getIcon(objectMetadataItem.icon)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</NavigationDrawerSection>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@ -2,21 +2,34 @@ import styled from '@emotion/styled';
|
||||
|
||||
import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading';
|
||||
import { NavigationDrawerSectionTitleSkeletonLoader } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitleSkeletonLoader';
|
||||
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
|
||||
|
||||
type NavigationDrawerSectionTitleProps = {
|
||||
onClick?: () => void;
|
||||
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};
|
||||
display: flex;
|
||||
font-size: ${({ theme }) => theme.font.size.xs};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
height: ${({ theme }) => theme.spacing(4)};
|
||||
padding: ${({ theme }) => theme.spacing(1)};
|
||||
padding-top: 0;
|
||||
|
||||
${({ onClick, theme }) =>
|
||||
!isUndefinedOrNull(onClick)
|
||||
? `&:hover {
|
||||
cursor: pointer;
|
||||
background-color:${theme.background.transparent.light};
|
||||
}`
|
||||
: ''}
|
||||
`;
|
||||
|
||||
export const NavigationDrawerSectionTitle = ({
|
||||
onClick,
|
||||
label,
|
||||
}: NavigationDrawerSectionTitleProps) => {
|
||||
const loading = useIsPrefetchLoading();
|
||||
@ -24,5 +37,5 @@ export const NavigationDrawerSectionTitle = ({
|
||||
if (loading) {
|
||||
return <NavigationDrawerSectionTitleSkeletonLoader />;
|
||||
}
|
||||
return <StyledTitle>{label}</StyledTitle>;
|
||||
return <StyledTitle onClick={onClick}>{label}</StyledTitle>;
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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()],
|
||||
});
|
||||
@ -1,16 +1,19 @@
|
||||
import { atomFamily } from 'recoil';
|
||||
import { AtomEffect, atomFamily } from 'recoil';
|
||||
|
||||
import { ComponentStateKey } from '@/ui/utilities/state/component-state/types/ComponentStateKey';
|
||||
|
||||
export const createComponentState = <ValueType>({
|
||||
key,
|
||||
defaultValue,
|
||||
effects,
|
||||
}: {
|
||||
key: string;
|
||||
defaultValue: ValueType;
|
||||
effects?: AtomEffect<ValueType>[];
|
||||
}) => {
|
||||
return atomFamily<ValueType, ComponentStateKey>({
|
||||
key,
|
||||
default: defaultValue,
|
||||
effects: effects,
|
||||
});
|
||||
};
|
||||
|
||||
@ -5,17 +5,17 @@ import { cookieStorage } from '~/utils/cookie-storage';
|
||||
import { isDefined } from './isDefined';
|
||||
|
||||
export const localStorageEffect =
|
||||
<T>(key: string): AtomEffect<T> =>
|
||||
({ setSelf, onSet }) => {
|
||||
const savedValue = localStorage.getItem(key);
|
||||
<T>(key?: string): AtomEffect<T> =>
|
||||
({ setSelf, onSet, node }) => {
|
||||
const savedValue = localStorage.getItem(key ?? node.key);
|
||||
if (savedValue != null) {
|
||||
setSelf(JSON.parse(savedValue));
|
||||
}
|
||||
|
||||
onSet((newValue, _, isReset) => {
|
||||
isReset
|
||||
? localStorage.removeItem(key)
|
||||
: localStorage.setItem(key, JSON.stringify(newValue));
|
||||
? localStorage.removeItem(key ?? node.key)
|
||||
: localStorage.setItem(key ?? node.key, JSON.stringify(newValue));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user