feat: fix Command Menu Side Panel Layout (#15883)
[Figma Design](https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=81380-344641&t=FpjWNOK2gZuDQQfr-0) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds a side panel layout for the Command Menu, routes modals into a local container, updates the top bar and context chips, and standardizes small button sizes. > > - **Command Menu**: > - **Side Panel Layout**: Introduces `CommandMenuSidePanelLayout` with animated width, hosts `CommandMenuRouter`, and provides a modal container via `ModalContainerContext`. > - **Top Bar**: Redesign (`CommandMenuTopBar`) with back icon, optional AI sparkles action, compact height (`COMMAND_MENU_SEARCH_BAR_HEIGHT=40`), and updated placeholder. > - **Context Chips**: Adds `CommandMenuLastContextChip` and `CommandMenuRecordInfo`; extends `CommandMenuContextChip` with `page` prop; updates `CommandMenuContextChipGroups` to render last chip as record info when applicable. > - **Container Simplification**: `CommandMenuContainer` simplified to just provide contexts and `AgentChatProvider`. > - **Modal System**: > - Adds `ModalContainerContext` and updates `Modal` to portal into provided container; `Modal.Backdrop` supports `isInContainer`. > - Updates usages (e.g., `UserOrMetadataLoader`, `ActionModal`) to align with new modal behavior. > - **Page Integration**: > - Replaces `PageBody` with `CommandMenuSidePanelLayout` in `RecordShowPage` and `RecordIndexContainerGater`. > - Removes global `CommandMenuRouter` from `DefaultLayout` (keeps keyboard shortcuts). > - **UI/Styling**: > - Standardizes several buttons to `size="small"` (e.g., command actions, open record, options, reply, workflow footer). > - Adjusts `ShowPageSubContainer` styling when rendered inside command menu. > - Storybook tests updated for new placeholder text. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 81fcaa145618a2fa1c3e11e2dc88fe832c51374c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Félix Malfait <felix.malfait@gmail.com> Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com> Co-authored-by: Devessier <baptiste@devessier.fr> Co-authored-by: Aman Raj <92664006+araj00@users.noreply.github.com> Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
import { CommandMenuRouter } from '@/command-menu/components/CommandMenuRouter';
|
||||
import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKeys';
|
||||
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
|
||||
import { tableWidthResizeIsActiveState } from '@/object-record/record-table/states/tableWidthResizeIsActivedState';
|
||||
import { ModalContainerContext } from '@/ui/layout/modal/contexts/ModalContainerContext';
|
||||
import { PageBody } from '@/ui/layout/page/components/PageBody';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { motion } from 'framer-motion';
|
||||
import { type ReactNode, useCallback, useState } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { useIsMobile } from 'twenty-ui/utilities';
|
||||
|
||||
type CommandMenuPageLayoutProps = {
|
||||
children: ReactNode;
|
||||
isSidePanelOpen?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_SIDE_PANEL_WIDTH = 400;
|
||||
|
||||
const StyledLayout = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding-bottom: ${({ theme }) => theme.spacing(3)};
|
||||
padding-right: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
const StyledPageBody = styled(PageBody)`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-bottom: 0;
|
||||
padding-right: 0;
|
||||
`;
|
||||
|
||||
const StyledSidePanelWrapper = styled(motion.div)`
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledSidePanel = styled(motion.aside)`
|
||||
background: ${({ theme }) => theme.background.primary};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: ${DEFAULT_SIDE_PANEL_WIDTH}px;
|
||||
box-sizing: border-box;
|
||||
`;
|
||||
|
||||
const StyledModalContainer = styled.div`
|
||||
height: 100%;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
export const CommandMenuPageLayout = ({
|
||||
children,
|
||||
isSidePanelOpen,
|
||||
}: CommandMenuPageLayoutProps) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useIsMobile();
|
||||
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
|
||||
const [modalContainer, setModalContainer] = useState<HTMLDivElement | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const setTableWidthResizeIsActive = useSetRecoilState(
|
||||
tableWidthResizeIsActiveState,
|
||||
);
|
||||
|
||||
const resolvedIsSidePanelOpen =
|
||||
isSidePanelOpen ?? isCommandMenuOpened ?? false;
|
||||
|
||||
const [shouldRenderContent, setShouldRenderContent] = useState(
|
||||
resolvedIsSidePanelOpen,
|
||||
);
|
||||
|
||||
const shouldShowContent = resolvedIsSidePanelOpen || shouldRenderContent;
|
||||
|
||||
const handleAnimationComplete = () => {
|
||||
if (!resolvedIsSidePanelOpen) {
|
||||
setShouldRenderContent(false);
|
||||
}
|
||||
|
||||
setTableWidthResizeIsActive(true);
|
||||
};
|
||||
|
||||
const handleAnimationStart = () => {
|
||||
if (resolvedIsSidePanelOpen && !shouldRenderContent) {
|
||||
setShouldRenderContent(true);
|
||||
}
|
||||
|
||||
setTableWidthResizeIsActive(false);
|
||||
};
|
||||
|
||||
const handleModalContainerRef = useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
setModalContainer(element);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useCommandMenuHotKeys();
|
||||
|
||||
if (isMobile) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledLayout>
|
||||
<StyledPageBody>{children}</StyledPageBody>
|
||||
|
||||
<StyledSidePanelWrapper
|
||||
initial={false}
|
||||
animate={{
|
||||
width: resolvedIsSidePanelOpen ? DEFAULT_SIDE_PANEL_WIDTH : 0,
|
||||
marginLeft: resolvedIsSidePanelOpen ? theme.spacing(2) : 0,
|
||||
}}
|
||||
transition={{
|
||||
duration: theme.animation.duration.normal,
|
||||
}}
|
||||
onAnimationStart={handleAnimationStart}
|
||||
onAnimationComplete={handleAnimationComplete}
|
||||
>
|
||||
<StyledSidePanel
|
||||
initial={false}
|
||||
animate={{
|
||||
x: resolvedIsSidePanelOpen ? 0 : DEFAULT_SIDE_PANEL_WIDTH,
|
||||
}}
|
||||
transition={{
|
||||
duration: theme.animation.duration.normal,
|
||||
}}
|
||||
>
|
||||
<StyledModalContainer ref={handleModalContainerRef} />
|
||||
<ModalContainerContext.Provider value={{ container: modalContainer }}>
|
||||
{shouldShowContent && <CommandMenuRouter />}
|
||||
</ModalContainerContext.Provider>
|
||||
</StyledSidePanel>
|
||||
</StyledSidePanelWrapper>
|
||||
</StyledLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import { CommandMenuRouter } from '@/command-menu/components/CommandMenuRouter';
|
||||
import { COMMAND_MENU_SIDE_PANEL_WIDTH } from '@/command-menu/constants/CommandMenuSidePanelWidth';
|
||||
import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKeys';
|
||||
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
|
||||
import { tableWidthResizeIsActiveState } from '@/object-record/record-table/states/tableWidthResizeIsActivedState';
|
||||
import { ModalContainerContext } from '@/ui/layout/modal/contexts/ModalContainerContext';
|
||||
import { PageBody } from '@/ui/layout/page/components/PageBody';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { motion } from 'framer-motion';
|
||||
import { type ReactNode, useCallback, useState } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { useIsMobile } from 'twenty-ui/utilities';
|
||||
|
||||
type CommandMenuPageLayoutProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const StyledLayout = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding-bottom: ${({ theme }) => theme.spacing(3)};
|
||||
padding-right: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
const StyledPageBody = styled(PageBody)`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-bottom: 0;
|
||||
padding-right: 0;
|
||||
`;
|
||||
|
||||
const StyledSidePanelWrapper = styled(motion.div)`
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledSidePanel = styled(motion.aside)`
|
||||
background: ${({ theme }) => theme.background.primary};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: ${COMMAND_MENU_SIDE_PANEL_WIDTH}px;
|
||||
box-sizing: border-box;
|
||||
`;
|
||||
|
||||
const StyledModalContainer = styled.div`
|
||||
height: 100%;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
export const CommandMenuPageLayout = ({
|
||||
children,
|
||||
}: CommandMenuPageLayoutProps) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useIsMobile();
|
||||
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
|
||||
const [modalContainer, setModalContainer] = useState<HTMLDivElement | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const setTableWidthResizeIsActive = useSetRecoilState(
|
||||
tableWidthResizeIsActiveState,
|
||||
);
|
||||
|
||||
const [shouldRenderContent, setShouldRenderContent] =
|
||||
useState(isCommandMenuOpened);
|
||||
|
||||
const shouldShowContent = isCommandMenuOpened || shouldRenderContent;
|
||||
|
||||
const handleAnimationComplete = () => {
|
||||
if (!isCommandMenuOpened) {
|
||||
setShouldRenderContent(false);
|
||||
}
|
||||
|
||||
setTableWidthResizeIsActive(true);
|
||||
};
|
||||
|
||||
const handleAnimationStart = () => {
|
||||
if (isCommandMenuOpened && !shouldRenderContent) {
|
||||
setShouldRenderContent(true);
|
||||
}
|
||||
|
||||
setTableWidthResizeIsActive(false);
|
||||
};
|
||||
|
||||
const handleModalContainerRef = useCallback(
|
||||
(element: HTMLDivElement | null) => {
|
||||
setModalContainer(element);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useCommandMenuHotKeys();
|
||||
|
||||
if (isMobile) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledLayout>
|
||||
<StyledPageBody>{children}</StyledPageBody>
|
||||
|
||||
<StyledSidePanelWrapper
|
||||
initial={false}
|
||||
animate={{
|
||||
width: isCommandMenuOpened ? COMMAND_MENU_SIDE_PANEL_WIDTH : 0,
|
||||
marginLeft: isCommandMenuOpened ? theme.spacing(2) : 0,
|
||||
}}
|
||||
transition={{
|
||||
duration: theme.animation.duration.normal,
|
||||
}}
|
||||
onAnimationStart={handleAnimationStart}
|
||||
onAnimationComplete={handleAnimationComplete}
|
||||
>
|
||||
<StyledSidePanel
|
||||
initial={false}
|
||||
animate={{
|
||||
x: isCommandMenuOpened ? 0 : COMMAND_MENU_SIDE_PANEL_WIDTH,
|
||||
}}
|
||||
transition={{
|
||||
duration: theme.animation.duration.normal,
|
||||
}}
|
||||
>
|
||||
<StyledModalContainer ref={handleModalContainerRef} />
|
||||
<ModalContainerContext.Provider value={{ container: modalContainer }}>
|
||||
{shouldShowContent && <CommandMenuRouter />}
|
||||
</ModalContainerContext.Provider>
|
||||
</StyledSidePanel>
|
||||
</StyledSidePanelWrapper>
|
||||
</StyledLayout>
|
||||
);
|
||||
};
|
||||
+3
-3
@@ -3,6 +3,7 @@ import { RecordIndexContextProvider } from '@/object-record/record-index/context
|
||||
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
|
||||
import { getActionMenuIdFromRecordIndexId } from '@/action-menu/utils/getActionMenuIdFromRecordIndexId';
|
||||
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
|
||||
import { CommandMenuPageLayout } from '@/object-record/components/CommandMenuPageLayout';
|
||||
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
|
||||
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
|
||||
import { lastShowPageRecordIdState } from '@/object-record/record-field/ui/states/lastShowPageRecordId';
|
||||
@@ -13,7 +14,6 @@ import { RecordIndexPageHeader } from '@/object-record/record-index/components/R
|
||||
import { useHandleIndexIdentifierClick } from '@/object-record/record-index/hooks/useHandleIndexIdentifierClick';
|
||||
import { useRecordIndexFieldMetadataDerivedStates } from '@/object-record/record-index/hooks/useRecordIndexFieldMetadataDerivedStates';
|
||||
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
|
||||
import { PageBody } from '@/ui/layout/page/components/PageBody';
|
||||
import { RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS } from '@/ui/utilities/drag-select/constants/RecordIndecDragSelectBoundaryClass';
|
||||
import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle';
|
||||
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
|
||||
@@ -98,14 +98,14 @@ export const RecordIndexContainerGater = () => {
|
||||
>
|
||||
<PageTitle title={objectMetadataItem.labelPlural} />
|
||||
<RecordIndexPageHeader />
|
||||
<PageBody>
|
||||
<CommandMenuPageLayout>
|
||||
<StyledIndexContainer
|
||||
className={RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS}
|
||||
>
|
||||
<RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect />
|
||||
<RecordIndexContainer />
|
||||
</StyledIndexContainer>
|
||||
</PageBody>
|
||||
</CommandMenuPageLayout>
|
||||
</ActionMenuComponentInstanceContext.Provider>
|
||||
</RecordComponentInstanceContextsWrapper>
|
||||
<RecordIndexLoadBaseOnContextStoreEffect />
|
||||
|
||||
+26
-19
@@ -1,8 +1,10 @@
|
||||
import { recordTableWidthComponentState } from '@/object-record/record-table/states/recordTableWidthComponentState';
|
||||
import { tableWidthResizeIsActiveState } from '@/object-record/record-table/states/tableWidthResizeIsActivedState';
|
||||
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
|
||||
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const RecordTableWidthEffect = () => {
|
||||
@@ -10,33 +12,38 @@ export const RecordTableWidthEffect = () => {
|
||||
recordTableWidthComponentState,
|
||||
);
|
||||
|
||||
const tableWidthResizeIsActive = useRecoilValue(
|
||||
tableWidthResizeIsActiveState,
|
||||
);
|
||||
|
||||
const { scrollWrapperHTMLElement } = useScrollWrapperHTMLElement();
|
||||
|
||||
useEffect(() => {
|
||||
const tableWidth = scrollWrapperHTMLElement?.clientWidth ?? 0;
|
||||
|
||||
if (tableWidth > 0) {
|
||||
setRecordTableWidth(tableWidth);
|
||||
}
|
||||
|
||||
const tableResizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.target === scrollWrapperHTMLElement) {
|
||||
const newWidth = scrollWrapperHTMLElement.clientWidth;
|
||||
|
||||
setRecordTableWidth(newWidth);
|
||||
}
|
||||
if (tableWidthResizeIsActive) {
|
||||
if (tableWidth > 0) {
|
||||
setRecordTableWidth(tableWidth);
|
||||
}
|
||||
});
|
||||
|
||||
if (isDefined(scrollWrapperHTMLElement)) {
|
||||
tableResizeObserver.observe(scrollWrapperHTMLElement);
|
||||
const tableResizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.target === scrollWrapperHTMLElement) {
|
||||
const newWidth = scrollWrapperHTMLElement.clientWidth;
|
||||
setRecordTableWidth(newWidth);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (isDefined(scrollWrapperHTMLElement)) {
|
||||
tableResizeObserver.observe(scrollWrapperHTMLElement);
|
||||
}
|
||||
|
||||
return () => {
|
||||
tableResizeObserver.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
tableResizeObserver.disconnect();
|
||||
};
|
||||
}, [setRecordTableWidth, scrollWrapperHTMLElement]);
|
||||
}, [setRecordTableWidth, scrollWrapperHTMLElement, tableWidthResizeIsActive]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { createState } from 'twenty-ui/utilities';
|
||||
|
||||
export const tableWidthResizeIsActiveState = createState({
|
||||
key: 'tableWidthResizeIsActiveState',
|
||||
defaultValue: true,
|
||||
});
|
||||
+1
-1
@@ -26,7 +26,7 @@ import { useRecoilCallback } from 'recoil';
|
||||
|
||||
type RecordTitleCellProps = {
|
||||
loading?: boolean;
|
||||
sizeVariant?: 'xs' | 'md';
|
||||
sizeVariant?: 'xs' | 'sm' | 'md';
|
||||
containerType: RecordTitleCellContainerType;
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import { RecordTitleFullNameFieldInput } from '@/object-record/record-title-cell
|
||||
|
||||
type RecordTitleCellFieldInputProps = {
|
||||
instanceId: string;
|
||||
sizeVariant?: 'xs' | 'md';
|
||||
sizeVariant?: 'xs' | 'sm' | 'md';
|
||||
};
|
||||
|
||||
export const RecordTitleCellFieldInput = ({
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import { turnIntoUndefinedIfWhitespacesOnly } from '~/utils/string/turnIntoUndef
|
||||
|
||||
type RecordTitleCellTextFieldInputProps = {
|
||||
instanceId: string;
|
||||
sizeVariant?: 'xs' | 'md';
|
||||
sizeVariant?: 'xs' | 'sm' | 'md';
|
||||
};
|
||||
|
||||
export const RecordTitleCellTextFieldInput = ({
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ type RecordTitleDoubleTextInputProps = {
|
||||
) => void;
|
||||
onChange?: (newDoubleTextValue: FieldDoubleText) => void;
|
||||
onPaste?: (newDoubleTextValue: FieldDoubleText) => void;
|
||||
sizeVariant?: 'xs' | 'md';
|
||||
sizeVariant?: 'xs' | 'sm' | 'md';
|
||||
};
|
||||
|
||||
export const RecordTitleDoubleTextInput = ({
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ import { useContext } from 'react';
|
||||
import { RecordTitleDoubleTextInput } from './RecordTitleDoubleTextInput';
|
||||
|
||||
type RecordTitleFullNameFieldInputProps = {
|
||||
sizeVariant?: 'xs' | 'md';
|
||||
sizeVariant?: 'xs' | 'sm' | 'md';
|
||||
};
|
||||
|
||||
export const RecordTitleFullNameFieldInput = ({
|
||||
|
||||
Reference in New Issue
Block a user