Feat: Navbar customization (#17728)

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: Devessier <baptiste@devessier.fr>
This commit is contained in:
Abdul Rahman
2026-02-11 21:03:51 +05:30
committed by GitHub
parent 52e57e70fd
commit 0902579fbe
166 changed files with 7716 additions and 667 deletions
@@ -0,0 +1,28 @@
import { Draggable } from '@hello-pangea/dnd';
import { type ReactNode } from 'react';
type CommandMenuAddToNavDraggablePlaceholderProps = {
index: number;
children: ReactNode;
};
export const CommandMenuAddToNavDraggablePlaceholder = ({
index,
children,
}: CommandMenuAddToNavDraggablePlaceholderProps) => (
<Draggable
draggableId={`add-to-nav-placeholder-${index}`}
index={index}
isDragDisabled={true}
>
{(provided) => (
<div
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.draggableProps}
>
{children}
</div>
)}
</Draggable>
);
@@ -0,0 +1,25 @@
import { Droppable, type DroppableProvided } from '@hello-pangea/dnd';
import { type ReactNode, useContext } from 'react';
import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/constants/AddToNavSourceDroppableId';
import { NavigationDragSourceContext } from '@/navigation-menu-item/contexts/NavigationDragSourceContext';
type CommandMenuAddToNavDroppableProps = {
children: (provided: DroppableProvided) => ReactNode;
};
export const CommandMenuAddToNavDroppable = ({
children,
}: CommandMenuAddToNavDroppableProps) => {
const { sourceDroppableId } = useContext(NavigationDragSourceContext);
const isDropDisabled = sourceDroppableId === ADD_TO_NAV_SOURCE_DROPPABLE_ID;
return (
<Droppable
droppableId={ADD_TO_NAV_SOURCE_DROPPABLE_ID}
isDropDisabled={isDropDisabled}
>
{(provided) => children(provided)}
</Droppable>
);
};
@@ -0,0 +1,116 @@
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { useRecoilValue } from 'recoil';
import { IconFolder, IconLink } from 'twenty-ui/display';
import { CommandMenuPageInfoLayout } from '@/command-menu/components/CommandMenuPageInfoLayout';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { commandMenuShouldFocusTitleInputComponentState } from '@/command-menu/states/commandMenuShouldFocusTitleInputComponentState';
import { StyledNavigationMenuItemIconContainer } from '@/navigation-menu-item/components/NavigationMenuItemIconContainer';
import { useUpdateFolderNameInDraft } from '@/navigation-menu-item/hooks/useUpdateFolderNameInDraft';
import { useUpdateLinkInDraft } from '@/navigation-menu-item/hooks/useUpdateLinkInDraft';
import { useWorkspaceSectionItems } from '@/navigation-menu-item/hooks/useWorkspaceSectionItems';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState';
import { getNavigationMenuItemIconColors } from '@/navigation-menu-item/utils/getNavigationMenuItemIconColors';
import { TitleInput } from '@/ui/input/components/TitleInput';
import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
const ICON_CONFIG = {
folder: { Icon: IconFolder, colorKey: 'folder' },
link: { Icon: IconLink, colorKey: 'link' },
} as const;
export const CommandMenuFolderLinkInfo = ({
type,
}: {
type: 'folder' | 'link';
}) => {
const theme = useTheme();
const { t } = useLingui();
const commandMenuPageInfo = useRecoilValue(commandMenuPageInfoState);
const [shouldFocusTitleInput, setShouldFocusTitleInput] =
useRecoilComponentState(
commandMenuShouldFocusTitleInputComponentState,
commandMenuPageInfo.instanceId,
);
const selectedNavigationMenuItemInEditMode = useRecoilValue(
selectedNavigationMenuItemInEditModeState,
);
const items = useWorkspaceSectionItems();
const { updateFolderNameInDraft } = useUpdateFolderNameInDraft();
const { updateLinkInDraft } = useUpdateLinkInDraft();
const defaultLabel = type === 'folder' ? t`New folder` : t`Link label`;
const placeholder = type === 'folder' ? t`Folder name` : t`Link label`;
const selectedItem = selectedNavigationMenuItemInEditMode
? items.find(
(item) =>
item.itemType === type &&
item.id === selectedNavigationMenuItemInEditMode,
)
: undefined;
if (!selectedItem) return null;
const itemId = selectedItem.id;
const itemName = selectedItem.name ?? defaultLabel;
const handleChange = (text: string) => {
if (type === 'folder') {
updateFolderNameInDraft(itemId, text);
} else {
updateLinkInDraft(itemId, { name: text });
}
};
const handleSave = () => {
const trimmed = itemName.trim();
const finalName = trimmed.length > 0 ? trimmed : defaultLabel;
if (finalName !== itemName) {
if (type === 'folder') {
updateFolderNameInDraft(itemId, finalName);
} else {
updateLinkInDraft(itemId, { name: finalName });
}
}
};
const { Icon, colorKey } = ICON_CONFIG[type];
return (
<CommandMenuPageInfoLayout
icon={
<StyledNavigationMenuItemIconContainer
$backgroundColor={getNavigationMenuItemIconColors(theme)[colorKey]}
>
<Icon
size={theme.spacing(3.5)}
color={theme.grayScale.gray1}
stroke={theme.icon.stroke.md}
/>
</StyledNavigationMenuItemIconContainer>
}
title={
<TitleInput
instanceId={
type === 'folder' ? `folder-name-${itemId}` : `link-label-${itemId}`
}
sizeVariant="sm"
value={itemName}
onChange={handleChange}
placeholder={placeholder}
onEnter={handleSave}
onEscape={handleSave}
onClickOutside={handleSave}
onTab={handleSave}
onShiftTab={handleSave}
shouldFocus={shouldFocusTitleInput}
onFocus={() => setShouldFocusTitleInput(false)}
/>
}
label={type === 'link' ? t`link` : undefined}
/>
);
};
@@ -1,11 +1,11 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type ReactNode } from 'react';
import { IconArrowUpRight, type IconComponent } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
import { useCommandMenuOnItemClick } from '@/command-menu/hooks/useCommandMenuOnItemClick';
import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector';
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import { type ReactNode } from 'react';
import { IconArrowUpRight, type IconComponent } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
export type CommandMenuItemProps = {
label: string;
@@ -15,6 +15,7 @@ export type CommandMenuItemProps = {
onClick?: () => void;
Icon?: IconComponent;
hotKeys?: string[];
LeftComponent?: ReactNode;
RightComponent?: ReactNode;
contextualTextPosition?: 'left' | 'right';
hasSubMenu?: boolean;
@@ -31,6 +32,7 @@ export const CommandMenuItem = ({
onClick,
Icon,
hotKeys,
LeftComponent,
RightComponent,
hasSubMenu = false,
isSubMenuOpened = false,
@@ -49,8 +51,9 @@ export const CommandMenuItem = ({
return (
<MenuItem
withIconContainer={true}
LeftIcon={Icon}
withIconContainer={!LeftComponent}
LeftIcon={LeftComponent ? undefined : Icon}
LeftComponent={LeftComponent}
text={label}
contextualText={description}
contextualTextPosition={contextualTextPosition}
@@ -0,0 +1,104 @@
import styled from '@emotion/styled';
import { Draggable } from '@hello-pangea/dnd';
import { useLingui } from '@lingui/react/macro';
import { type ReactNode, useState } from 'react';
import { useSetRecoilState } from 'recoil';
import { type IconComponent } from 'twenty-ui/display';
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { AddToNavigationDragHandle } from '@/navigation-menu-item/components/AddToNavigationDragHandle';
import { addToNavPayloadRegistryState } from '@/navigation-menu-item/states/addToNavPayloadRegistryState';
import type { AddToNavigationDragPayload } from '@/navigation-menu-item/types/add-to-navigation-drag-payload';
type CommandMenuItemWithAddToNavigationDragProps = {
icon?: IconComponent;
customIconContent?: ReactNode;
label: string;
description?: string;
id: string;
onClick: () => void;
payload: AddToNavigationDragPayload;
dragIndex?: number;
};
const StyledDraggableMenuItem = styled.div`
cursor: grab;
width: 100%;
&:active {
cursor: grabbing;
}
`;
export const CommandMenuItemWithAddToNavigationDrag = ({
icon,
customIconContent,
label,
description,
id,
onClick,
payload,
dragIndex,
}: CommandMenuItemWithAddToNavigationDragProps) => {
const { t } = useLingui();
const setRegistry = useSetRecoilState(addToNavPayloadRegistryState);
const [isHovered, setIsHovered] = useState(false);
const contextualDescription = isHovered
? t`Drag to add to navbar`
: description;
const DragHandleIcon = () => (
<AddToNavigationDragHandle
icon={icon}
customIconContent={customIconContent}
payload={payload}
isHovered={isHovered}
/>
);
const registerPayload = () => {
if (dragIndex !== undefined) {
setRegistry((prev) => new Map(prev).set(id, payload));
}
};
const menuItemContent = (
<StyledDraggableMenuItem
onMouseEnter={() => {
setIsHovered(true);
registerPayload();
}}
onMouseLeave={() => setIsHovered(false)}
onMouseDown={registerPayload}
>
<CommandMenuItem
Icon={DragHandleIcon}
label={label}
description={contextualDescription}
id={id}
onClick={onClick}
/>
</StyledDraggableMenuItem>
);
if (dragIndex !== undefined) {
return (
<Draggable draggableId={id} index={dragIndex} isDragDisabled={false}>
{(provided) => (
<div
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.draggableProps}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.dragHandleProps}
>
{menuItemContent}
</div>
)}
</Draggable>
);
}
return menuItemContent;
};
@@ -20,6 +20,7 @@ export type CommandMenuListProps = {
children?: React.ReactNode;
loading?: boolean;
noResults?: boolean;
noResultsText?: string;
};
const StyledInnerList = styled.div`
@@ -60,6 +61,7 @@ export const CommandMenuList = ({
children,
loading = false,
noResults = false,
noResultsText,
}: CommandMenuListProps) => {
const setHasUserSelectedCommand = useSetRecoilState(
hasUserSelectedCommandState,
@@ -91,7 +93,7 @@ export const CommandMenuList = ({
) : null,
)}
{noResults && !loading && (
<StyledEmpty>{t`No results found`}</StyledEmpty>
<StyledEmpty>{noResultsText ?? t`No results found`}</StyledEmpty>
)}
</SelectableList>
</StyledInnerList>
@@ -0,0 +1,38 @@
import { useLingui } from '@lingui/react/macro';
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
import { CommandMenuPageInfoLayout } from '@/command-menu/components/CommandMenuPageInfoLayout';
import { useSelectedNavigationMenuItemEditData } from '@/command-menu/pages/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditData';
import { NavigationMenuItemIcon } from '@/navigation-menu-item/components/NavigationMenuItemIcon';
import { ViewKey } from '@/views/types/ViewKey';
export const CommandMenuObjectViewRecordInfo = () => {
const { t } = useLingui();
const { processedItem, selectedItemLabel } =
useSelectedNavigationMenuItemEditData();
if (!processedItem || !selectedItemLabel) {
return null;
}
const isViewOrRecord =
processedItem.itemType === 'view' || processedItem.itemType === 'record';
if (!isViewOrRecord) {
return null;
}
const label =
processedItem.itemType === 'record'
? t`record`
: processedItem.viewKey === ViewKey.Index
? t`object`
: t`view`;
return (
<CommandMenuPageInfoLayout
icon={<NavigationMenuItemIcon navigationMenuItem={processedItem} />}
title={<OverflowingTextWithTooltip text={selectedItemLabel} />}
label={label}
/>
);
};
@@ -1,11 +1,18 @@
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
import { CommandMenuFolderLinkInfo } from '@/command-menu/components/CommandMenuFolderLinkInfo';
import { CommandMenuMultipleRecordsInfo } from '@/command-menu/components/CommandMenuMultipleRecordsInfo';
import { CommandMenuObjectViewRecordInfo } from '@/command-menu/components/CommandMenuObjectViewRecordInfo';
import { CommandMenuPageLayoutInfo } from '@/command-menu/components/CommandMenuPageLayoutInfo';
import { CommandMenuRecordInfo } from '@/command-menu/components/CommandMenuRecordInfo';
import { CommandMenuWorkflowStepInfo } from '@/command-menu/components/CommandMenuWorkflowStepInfo';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared/utils';
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
import { useWorkspaceSectionItems } from '@/navigation-menu-item/hooks/useWorkspaceSectionItems';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState';
import { type CommandMenuContextChipProps } from './CommandMenuContextChip';
const StyledPageTitle = styled.div`
@@ -19,10 +26,33 @@ type CommandMenuPageInfoProps = {
};
export const CommandMenuPageInfo = ({ pageChip }: CommandMenuPageInfoProps) => {
const selectedNavigationMenuItemInEditMode = useRecoilValue(
selectedNavigationMenuItemInEditModeState,
);
const items = useWorkspaceSectionItems();
if (!isDefined(pageChip)) {
return null;
}
const isNavigationMenuItemEditPage =
pageChip.page?.page === CommandMenuPages.NavigationMenuItemEdit;
const selectedNavItem = isNavigationMenuItemEditPage
? items.find((item) => item.id === selectedNavigationMenuItemInEditMode)
: undefined;
if (isNavigationMenuItemEditPage && isDefined(selectedNavItem)) {
const itemType = selectedNavItem.itemType;
if (itemType === 'folder' || itemType === 'link') {
return <CommandMenuFolderLinkInfo type={itemType} />;
}
if (itemType === 'view' || itemType === 'record') {
return <CommandMenuObjectViewRecordInfo />;
}
}
const isRecordPage = pageChip.page?.page === CommandMenuPages.ViewRecord;
if (isRecordPage && isDefined(pageChip.page?.pageId)) {
@@ -134,6 +134,7 @@ export const CommandMenuSidePanelForDesktop = () => {
isOpen={isCommandMenuOpened}
isResizing={isResizing}
onTransitionEnd={handleTransitionEnd}
data-command-menu-panel=""
>
<StyledSidePanel>
<StyledModalContainer ref={handleModalContainerRef} />
@@ -0,0 +1,87 @@
import styled from '@emotion/styled';
import { type ReactNode } from 'react';
import { SidePanelSubPageNavigationHeader } from '@/command-menu/pages/common/components/SidePanelSubPageNavigationHeader';
const StyledSubViewContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
`;
const StyledSearchContainer = styled.div`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
min-width: 0;
padding: ${({ theme }) => theme.spacing(2, 3)};
`;
const StyledSearchInput = styled.input`
background: transparent;
border: none;
border-radius: ${({ theme }) => theme.border.radius.sm};
box-sizing: border-box;
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
padding: 0;
width: 100%;
outline: none;
&::placeholder {
color: ${({ theme }) => theme.font.color.tertiary};
}
`;
const StyledScrollableListWrapper = styled.div`
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
& > * {
flex: 1;
min-height: 0;
}
`;
type CommandMenuSubViewWithSearchProps = {
backBarTitle: string;
onBack: () => void;
searchPlaceholder: string;
searchValue: string;
onSearchChange: (value: string) => void;
searchInputProps?: React.InputHTMLAttributes<HTMLInputElement>;
children?: ReactNode;
};
export const CommandMenuSubViewWithSearch = ({
backBarTitle,
onBack,
searchPlaceholder,
searchValue,
onSearchChange,
searchInputProps,
children,
}: CommandMenuSubViewWithSearchProps) => (
<StyledSubViewContainer>
<SidePanelSubPageNavigationHeader
title={backBarTitle}
onBackClick={onBack}
/>
<StyledSearchContainer>
<StyledSearchInput
placeholder={searchPlaceholder}
value={searchValue}
onChange={(event) => onSearchChange(event.target.value)}
autoFocus
// eslint-disable-next-line react/jsx-props-no-spreading
{...searchInputProps}
/>
</StyledSearchContainer>
{children != null && (
<StyledScrollableListWrapper>{children}</StyledScrollableListWrapper>
)}
</StyledSubViewContainer>
);