[Twenty-front]: Bunch of View Picker Fixes and improvements. (#21290)

While working on #21208, I found a few related improvements and fixes
that were worth including in this PR.

1. Improved View Picker UX:
- Added optimistic updates when selecting a view from both the
drag-and-drop view picker
- Added optimistic updates when editing view. Before it used to close
the whole dropdown.
- Added highlighting for the currently selected view.
- Before:



https://github.com/user-attachments/assets/469fc60c-e65f-4452-a5a4-7df6188ab19d


- After:


https://github.com/user-attachments/assets/d3b151c1-0c10-45e7-a796-b5e6061c898d



2. Remove Favorites from the View Picker
- Added support for removing a favorite directly from the view picker
without needing to open additional menus.
- Before:


https://github.com/user-attachments/assets/70437fb9-d4c1-488b-aab9-0ea92d1bad99



- After:


https://github.com/user-attachments/assets/442546bd-24ae-43d5-abe1-268ef3ff6475

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Priyanshu Bartwal
2026-06-22 14:26:34 +05:30
committed by GitHub
parent bb12f426fd
commit 4c966bfc32
7 changed files with 86 additions and 44 deletions
@@ -285,6 +285,7 @@ export const ObjectOptionsDropdownCustomView = ({
LeftIcon={IconTrash}
text={t`Delete view`}
disabled={isDefaultView || isLastView}
accent="danger"
/>
</SelectableListItem>
</div>
@@ -44,6 +44,7 @@ export type MenuItemWithOptionDropdownProps = {
text: ReactNode;
hasSubMenu?: boolean;
dropdownPlacement?: Placement;
selected?: boolean;
};
// TODO: refactor this
@@ -62,6 +63,7 @@ export const MenuItemWithOptionDropdown = ({
text,
hasSubMenu = false,
dropdownPlacement = 'bottom-end',
selected = false,
}: MenuItemWithOptionDropdownProps) => {
const { theme } = useContext(ThemeContext);
const handleMenuItemClick = (event: MouseEvent<HTMLDivElement>) => {
@@ -81,6 +83,7 @@ export const MenuItemWithOptionDropdown = ({
isIconDisplayedOnHoverOnly={isIconDisplayedOnHoverOnly}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
focused={selected}
>
<StyledMenuItemLeftContent>
<MenuItemLeftContent LeftIcon={LeftIcon ?? undefined} text={text} />
@@ -1,10 +1,14 @@
import { useCallback } from 'react';
import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetadataErrorHandler';
import { useUpdateMetadataStoreDraft } from '@/metadata-store/hooks/useUpdateMetadataStoreDraft';
import { metadataStoreState } from '@/metadata-store/states/metadataStoreState';
import { type FlatView } from '@/metadata-store/types/FlatView';
import { type MetadataRequestResult } from '@/object-metadata/types/MetadataRequestResult.type';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { t } from '@lingui/core/macro';
import { useStore } from 'jotai';
import { CrudOperationType } from 'twenty-shared/types';
import { useMutation } from '@apollo/client/react';
import {
@@ -15,15 +19,27 @@ import {
export const usePerformViewAPIUpdate = () => {
const [updateViewMutation] = useMutation(UpdateViewDocument);
const { updateInDraft, applyChanges } = useUpdateMetadataStoreDraft();
const { handleMetadataError } = useMetadataErrorHandler();
const { enqueueErrorSnackBar } = useSnackBar();
const store = useStore();
const performViewAPIUpdate = useCallback(
async (
variables: UpdateViewMutationVariables,
): Promise<
MetadataRequestResult<Awaited<ReturnType<typeof updateViewMutation>>>
> => {
const viewsStoreAtom = metadataStoreState.atomFamily('views');
const previousViewsEntry = store.get(viewsStoreAtom);
updateInDraft('views', [
{ id: variables.id, ...variables.input } as FlatView,
]);
applyChanges();
try {
const result = await updateViewMutation({
variables,
@@ -34,6 +50,8 @@ export const usePerformViewAPIUpdate = () => {
response: result,
};
} catch (error) {
store.set(viewsStoreAtom, previousViewsEntry);
if (CombinedGraphQLErrors.is(error)) {
handleMetadataError(error, {
primaryMetadataName: 'view',
@@ -49,7 +67,14 @@ export const usePerformViewAPIUpdate = () => {
};
}
},
[updateViewMutation, handleMetadataError, enqueueErrorSnackBar],
[
updateViewMutation,
handleMetadataError,
enqueueErrorSnackBar,
updateInDraft,
applyChanges,
store,
],
);
return { performViewAPIUpdate };
@@ -60,7 +60,7 @@ export const ViewPickerContentEditMode = () => {
setViewPickerSelectedIcon(iconKey);
};
const handleClose = async () => {
const handleGoBack = async () => {
await updateViewFromCurrentState();
setViewPickerMode('list');
@@ -71,7 +71,7 @@ export const ViewPickerContentEditMode = () => {
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={handleClose}
onClick={handleGoBack}
Icon={IconChevronLeft}
/>
}
@@ -146,6 +146,7 @@ export const ViewPickerListContent = () => {
onDragEnd={handleWorkspaceDragEnd}
draggableItems={workspaceViews.map((view, index) => {
const isIndexView = view.key === 'INDEX';
const isCurrentView = currentView?.id === view.id;
return (
<DraggableItem
key={view.id}
@@ -159,6 +160,7 @@ export const ViewPickerListContent = () => {
isIndexView={isIndexView}
isLastView={isLastView}
onEdit={handleEditViewButtonClick}
isCurrentView={isCurrentView}
/>
}
/>
@@ -179,6 +181,7 @@ export const ViewPickerListContent = () => {
onDragEnd={handleUnlistedDragEnd}
draggableItems={unlistedViews.map((view, index) => {
const isIndexView = view.key === 'INDEX';
const isCurrentView = currentView?.id === view.id;
return (
<DraggableItem
key={view.id}
@@ -192,6 +195,7 @@ export const ViewPickerListContent = () => {
isIndexView={isIndexView}
isLastView={isLastView}
onEdit={handleEditViewButtonClick}
isCurrentView={isCurrentView}
/>
}
/>
@@ -1,4 +1,5 @@
import { useCreateManyNavigationMenuItems } from '@/navigation-menu-item/common/hooks/useCreateManyNavigationMenuItems';
import { useDeleteManyNavigationMenuItems } from '@/navigation-menu-item/common/hooks/useDeleteManyNavigationMenuItems';
import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData';
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
@@ -15,6 +16,7 @@ import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import {
IconHeart,
IconHeartOff,
IconLock,
IconPencil,
IconTrash,
@@ -35,6 +37,7 @@ type ViewPickerOptionDropdownProps = {
>;
onEdit: (event: React.MouseEvent<HTMLElement>, viewId: string) => void;
handleViewSelect: (viewId: string) => void;
isCurrentView: boolean;
};
export const ViewPickerOptionDropdown = ({
@@ -43,6 +46,7 @@ export const ViewPickerOptionDropdown = ({
onEdit,
view,
handleViewSelect,
isCurrentView,
}: ViewPickerOptionDropdownProps) => {
const dropdownId = `view-picker-options-${view.id}`;
@@ -59,16 +63,19 @@ export const ViewPickerOptionDropdown = ({
const { navigationMenuItems, currentUserWorkspaceId } =
useNavigationMenuItemsData();
const { deleteManyNavigationMenuItems } = useDeleteManyNavigationMenuItems();
// Users with VIEWS permission can edit all views
// Users without VIEWS permission can only edit unlisted views (which are always their own, filtered by backend)
const canEditView =
hasViewsPermission || view.visibility === ViewVisibility.UNLISTED;
const isFavorite = navigationMenuItems.some(
const currentNavigationMenuItem = navigationMenuItems.find(
(item) =>
item.viewId === view.id &&
item.userWorkspaceId === currentUserWorkspaceId,
);
const isFavorite = isDefined(currentNavigationMenuItem);
const handleDelete = () => {
setViewPickerReferenceViewId(view.id);
@@ -76,7 +83,7 @@ export const ViewPickerOptionDropdown = ({
closeDropdown(dropdownId);
};
const handleAddToFavorites = () => {
const handleToggleFavorite = () => {
if (!isFavorite) {
const relevantItems = navigationMenuItems.filter(
(item) => !isDefined(item.folderId) && isDefined(item.userWorkspaceId),
@@ -96,6 +103,8 @@ export const ViewPickerOptionDropdown = ({
position: maxPosition + 1,
},
]);
} else {
deleteManyNavigationMenuItems([currentNavigationMenuItem.id]);
}
closeDropdown(dropdownId);
};
@@ -120,42 +129,32 @@ export const ViewPickerOptionDropdown = ({
RightIcon={getVisibilityIcon()}
dropdownPlacement="bottom-start"
dropdownId={`view-picker-options-${view.id}`}
selected={isCurrentView}
dropdownContent={
<DropdownContent>
<DropdownMenuItemsContainer>
{isIndexView ? (
<MenuItem
LeftIcon={IconHeart}
text={isFavorite ? t`Manage favorite` : t`Add to Favorite`}
onClick={handleAddToFavorites}
/>
) : (
<MenuItem
LeftIcon={isFavorite ? IconHeartOff : IconHeart}
text={isFavorite ? t`Remove Favorite` : t`Add to Favorite`}
onClick={handleToggleFavorite}
/>
{!isIndexView && canEditView && (
<>
<MenuItem
LeftIcon={IconHeart}
text={isFavorite ? t`Manage favorite` : t`Add to Favorite`}
onClick={handleAddToFavorites}
LeftIcon={IconPencil}
text={t`Edit`}
onClick={(event) => {
onEdit(event, view.id);
closeDropdown(dropdownId);
}}
/>
{canEditView && (
<>
<MenuItem
LeftIcon={IconPencil}
text={t`Edit`}
onClick={(event) => {
onEdit(event, view.id);
closeDropdown(dropdownId);
}}
/>
{!isLastView && (
<MenuItem
LeftIcon={IconTrash}
text={t`Delete`}
onClick={handleDelete}
accent="danger"
/>
)}
</>
{!isLastView && (
<MenuItem
LeftIcon={IconTrash}
text={t`Delete`}
onClick={handleDelete}
accent="danger"
/>
)}
</>
)}
@@ -8,6 +8,7 @@ import { useCloseAndResetViewPicker } from '@/views/view-picker/hooks/useCloseAn
import { viewPickerInputNameComponentState } from '@/views/view-picker/states/viewPickerInputNameComponentState';
import { viewPickerIsDirtyComponentState } from '@/views/view-picker/states/viewPickerIsDirtyComponentState';
import { viewPickerIsPersistingComponentState } from '@/views/view-picker/states/viewPickerIsPersistingComponentState';
import { viewPickerModeComponentState } from '@/views/view-picker/states/viewPickerModeComponentState';
import { viewPickerReferenceViewIdComponentState } from '@/views/view-picker/states/viewPickerReferenceViewIdComponentState';
import { viewPickerSelectedIconComponentState } from '@/views/view-picker/states/viewPickerSelectedIconComponentState';
import { viewPickerVisibilityComponentState } from '@/views/view-picker/states/viewPickerVisibilityComponentState';
@@ -30,6 +31,10 @@ export const useUpdateViewFromCurrentState = () => {
viewPickerIsDirtyComponentState,
);
const viewPickerModeCallbackState = useAtomComponentStateCallbackState(
viewPickerModeComponentState,
);
const viewPickerReferenceViewIdCallbackState =
useAtomComponentStateCallbackState(viewPickerReferenceViewIdComponentState);
@@ -49,7 +54,7 @@ export const useUpdateViewFromCurrentState = () => {
store.set(viewPickerIsPersistingCallbackState, true);
store.set(viewPickerIsDirtyCallbackState, false);
closeAndResetViewPicker();
store.set(viewPickerModeCallbackState, 'list');
const viewPickerReferenceViewId = store.get(
viewPickerReferenceViewIdCallbackState,
@@ -60,18 +65,23 @@ export const useUpdateViewFromCurrentState = () => {
);
const visibility = store.get(viewPickerVisibilityCallbackState);
await performViewAPIUpdate({
id: viewPickerReferenceViewId,
input: {
name: viewPickerInputName,
icon: viewPickerSelectedIcon,
visibility: visibility,
},
});
try {
await performViewAPIUpdate({
id: viewPickerReferenceViewId,
input: {
name: viewPickerInputName,
icon: viewPickerSelectedIcon,
visibility: visibility,
},
});
} finally {
store.set(viewPickerIsPersistingCallbackState, false);
}
}, [
canPersistChanges,
viewPickerIsPersistingCallbackState,
viewPickerIsDirtyCallbackState,
viewPickerModeCallbackState,
closeAndResetViewPicker,
viewPickerReferenceViewIdCallbackState,
viewPickerInputNameCallbackState,