Compare commits

...

8 Commits

Author SHA1 Message Date
Félix Malfait dae999ca8d fix(twenty-front): restore the pinned create button and scope the primary to one action
- The header's right group now grows instead of sizing to content, so the
  pinned-buttons inline layout keeps its width and New Company no longer
  collapses into a hidden overflow.
- Only the leading pinned action renders as primary, threaded through an
  isPrimaryAction prop and applied to both the text and icon button paths,
  instead of hardcoding every pinned button to primary.
2026-06-07 15:32:19 +02:00
Félix Malfait 83162b1b80 feat(twenty-front): left-align the page title and give the header one primary action
- The card title now sits in the left group of the header instead of centered.
- Pinned header actions (e.g. New Company) render as the filled primary button,
  and the command-menu toggle drops to a quiet tertiary icon, so the header has a
  single clear primary instead of two grey outlines.
2026-06-07 14:36:03 +02:00
Félix Malfait 5485343c05 chore(twenty-front): drop node_modules symlinks tracked during worktree setup 2026-06-07 09:43:26 +02:00
Félix Malfait e8994eb0bb Merge branch 'main' into feat/app-page-card-layout 2026-06-07 08:28:12 +02:00
Félix Malfait c9b118baaa feat(twenty-front): make the command menu the primary page-header action
The ⌘K side-panel toggle is the action hub on every app page, so it renders
as the filled primary button while the panel is closed and steps back to a
plain secondary once it is open. Pinned quick-actions such as New Company stay
secondary, giving the header a single clear primary.
2026-06-06 15:53:43 +02:00
Félix Malfait 284d4124ff feat(twenty-front): extend the page card layout to record pages
- Record show now renders inside PageCardLayout with a PageCardHeader; the
  inline-editable record title goes through a new breadcrumb slot.
- The content card and the side panel share one frame: the framing padding
  lives on MainAppLayoutWithSidePanel, so the gap between them is just the
  resize gap (matching main) instead of stacking the card and panel margins.
- The first-load and navigation skeletons mirror the primary/secondary bar
  card, and the settings skeleton no longer pads itself now that the row owns
  the frame.
2026-06-06 14:38:58 +02:00
Félix Malfait 21deebad34 feat(twenty-front): give record index pages the settings card layout
Generalizes the settings card chrome into a shared PageCardLayout +
PageCardHeader (with an icon slot) and applies it to record index pages,
so the side panel sits beside a rounded card — consistent with settings —
instead of full-height against a flat table.

- New PageCardLayout (rounded card: primary bar, optional secondary bar,
  body) and PageCardHeader (centered title with an icon slot + optional
  breadcrumb) under @/ui/layout/page. SettingsPageLayout now delegates to
  them; the settings-only SettingsPageHeader is removed.
- Record index: the page header (object icon + label + actions) becomes the
  card primary bar; the view bar (view picker + filter/sort/options) is
  lifted into the secondary bar (RecordIndexViewBar); the table/board/
  calendar is the card body.
2026-06-06 13:21:43 +02:00
Félix Malfait b2957d9a29 fix(twenty-front): keep the AI side panel mounted across navigation
The AI chat side panel lived inside each per-page layout
(SettingsPageLayout and the record body container), so React unmounted
and remounted it on every navigation — the chat reloaded and lost its
state whenever you opened another record or settings page.

Hoist the side panel into a new persistent layout route,
MainAppLayoutWithSidePanel, that wraps every main-app route (records,
page layouts, settings). The panel is now a stable sibling of the routed
Outlet, so it stays mounted — and the AI chat keeps its state — while
only the page content reloads. PageChangeEffect already exempts the AI
chat from its close-on-navigation logic, so the panel now genuinely
persists instead of just re-opening.

- MainAppLayoutWithSidePanel owns the side panel + command-menu hotkeys;
  SettingsPageLayout and the record body container no longer render their
  own panel.
- MainContainerLayoutWithSidePanel renamed to MainContainerLayout since
  it no longer owns a panel.
- SidePanelForDesktop carries its own margin so it floats correctly as a
  top-level sibling; the margin collapses with the panel when closed, so
  pages render unchanged while it is closed.
- On a full reload the settings route shows the rounded-card skeleton
  (matching in-app navigation) instead of the legacy page skeleton.
2026-06-06 13:04:22 +02:00
20 changed files with 461 additions and 294 deletions
@@ -1,14 +1,29 @@
import { PageContentSkeletonLoader } from '~/loading/components/PageContentSkeletonLoader';
import { RecordIndexSkeletonLoader } from '@/object-record/record-index/components/RecordIndexSkeletonLoader';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { styled } from '@linaria/react';
import { useLocation } from 'react-router-dom';
import { AppPath } from 'twenty-shared/types';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
const StyledRightPanelContainer = styled.div`
display: flex;
flex-direction: column;
padding: ${themeCssVariables.spacing[2]};
width: 100%;
`;
export const RightPanelSkeletonLoader = () => (
<StyledRightPanelContainer>
<PageContentSkeletonLoader />
</StyledRightPanelContainer>
);
export const RightPanelSkeletonLoader = () => {
const location = useLocation();
const isSettingsPage = isMatchingLocation(location, AppPath.SettingsCatchAll);
return (
<StyledRightPanelContainer>
{isSettingsPage ? (
<SettingsSkeletonLoader />
) : (
<RecordIndexSkeletonLoader />
)}
</StyledRightPanelContainer>
);
};
@@ -7,6 +7,7 @@ import { VerifyEmailEffect } from '@/auth/components/VerifyEmailEffect';
import indexAppPath from '@/navigation/utils/indexAppPath';
import { BlankLayout } from '@/ui/layout/page/components/BlankLayout';
import { DefaultLayout } from '@/ui/layout/page/components/DefaultLayout';
import { MainAppLayoutWithSidePanel } from '@/ui/layout/page/components/MainAppLayoutWithSidePanel';
import { AppPath } from 'twenty-shared/types';
import { lazy } from 'react';
@@ -209,48 +210,50 @@ export const useCreateAppRouter = (
</LazyRoute>
}
/>
<Route path={indexAppPath.getIndexAppPath()} element={<></>} />
<Route
path={AppPath.RecordIndexPage}
element={
<LazyRoute>
<RecordIndexPage />
</LazyRoute>
}
/>
<Route
path={AppPath.RecordShowPage}
element={
<LazyRoute>
<RecordShowPage />
</LazyRoute>
}
/>
<Route
path={AppPath.PageLayoutPage}
element={
<LazyRoute>
<StandalonePageLayoutPage />
</LazyRoute>
}
/>
<Route
path={AppPath.SettingsCatchAll}
element={
<SettingsRoutes
isFunctionSettingsEnabled={isFunctionSettingsEnabled}
isAdminPageEnabled={isAdminPageEnabled}
/>
}
/>
<Route
path={AppPath.NotFoundWildcard}
element={
<LazyRoute>
<NotFound />
</LazyRoute>
}
/>
<Route element={<MainAppLayoutWithSidePanel />}>
<Route path={indexAppPath.getIndexAppPath()} element={<></>} />
<Route
path={AppPath.RecordIndexPage}
element={
<LazyRoute>
<RecordIndexPage />
</LazyRoute>
}
/>
<Route
path={AppPath.RecordShowPage}
element={
<LazyRoute>
<RecordShowPage />
</LazyRoute>
}
/>
<Route
path={AppPath.PageLayoutPage}
element={
<LazyRoute>
<StandalonePageLayoutPage />
</LazyRoute>
}
/>
<Route
path={AppPath.SettingsCatchAll}
element={
<SettingsRoutes
isFunctionSettingsEnabled={isFunctionSettingsEnabled}
isAdminPageEnabled={isAdminPageEnabled}
/>
}
/>
<Route
path={AppPath.NotFoundWildcard}
element={
<LazyRoute>
<NotFound />
</LazyRoute>
}
/>
</Route>
</Route>
<Route element={<BlankLayout />}>
<Route
@@ -31,12 +31,14 @@ const StyledPreviewWrapper = styled.div`
type CommandMenuItemRendererProps = {
item: CommandMenuItemFieldsFragment;
isPrimaryAction?: boolean;
};
type CommandMenuItemButtonRendererProps = CommandMenuItemRendererProps;
const CommandMenuItemButtonRenderer = ({
item,
isPrimaryAction = false,
}: CommandMenuItemButtonRendererProps) => {
const { commandMenuContextApi, isInPreviewMode } =
useContext(CommandMenuContext);
@@ -60,7 +62,10 @@ const CommandMenuItemButtonRenderer = ({
if (isInPreviewMode) {
return (
<StyledPreviewWrapper>
<CommandMenuButton command={command} />
<CommandMenuButton
command={command}
isPrimaryAction={isPrimaryAction}
/>
</StyledPreviewWrapper>
);
}
@@ -70,6 +75,7 @@ const CommandMenuItemButtonRenderer = ({
command={command}
onClick={disabled ? undefined : handleClick}
disabled={disabled}
isPrimaryAction={isPrimaryAction}
/>
);
};
@@ -167,11 +173,17 @@ const CommandMenuItemSelectableRenderer = ({
// oxlint-disable-next-line twenty/effect-components
export const CommandMenuItemRenderer = ({
item,
isPrimaryAction,
}: CommandMenuItemRendererProps) => {
const { displayType } = useContext(CommandMenuContext);
if (displayType === 'button') {
return <CommandMenuItemButtonRenderer item={item} />;
return (
<CommandMenuItemButtonRenderer
item={item}
isPrimaryAction={isPrimaryAction}
/>
);
}
if (displayType === 'listItem' || displayType === 'dropdownItem') {
@@ -68,7 +68,7 @@ export const PinnedCommandMenuItemButtons = () => {
<NodeDimension onDimensionChange={onContainerDimensionChange}>
<StyledContainer>
<StyledItemsContainer>
{pinnedInlineCommandMenuItems.map((item) => (
{pinnedInlineCommandMenuItems.map((item, index) => (
<StyledCommandMenuItemContainer
key={item.id}
layout
@@ -80,7 +80,10 @@ export const PinnedCommandMenuItemButtons = () => {
ease: 'easeInOut',
}}
>
<CommandMenuItemRenderer item={item} />
<CommandMenuItemRenderer
item={item}
isPrimaryAction={index === 0}
/>
</StyledCommandMenuItemContainer>
))}
</StyledItemsContainer>
@@ -28,6 +28,7 @@ export type CommandMenuButtonProps = {
onClick?: (event?: MouseEvent<HTMLElement>) => void;
to?: string;
disabled?: boolean;
isPrimaryAction?: boolean;
};
export const CommandMenuButton = ({
@@ -35,6 +36,7 @@ export const CommandMenuButton = ({
onClick,
to,
disabled = false,
isPrimaryAction = false,
}: CommandMenuButtonProps) => {
const resolvedLabel = getCommandMenuItemLabel(command.label);
@@ -50,8 +52,8 @@ export const CommandMenuButton = ({
<Button
Icon={command.Icon}
size="small"
variant="secondary"
accent={buttonAccent}
variant={isPrimaryAction ? 'primary' : 'secondary'}
accent={isPrimaryAction ? 'blue' : buttonAccent}
to={to}
onClick={onClick}
disabled={disabled}
@@ -63,8 +65,8 @@ export const CommandMenuButton = ({
<IconButton
Icon={command.Icon}
size="small"
variant="secondary"
accent={buttonAccent}
variant={isPrimaryAction ? 'primary' : 'secondary'}
accent={isPrimaryAction ? 'blue' : buttonAccent}
to={to}
onClick={onClick}
disabled={disabled}
@@ -1,13 +1,10 @@
import { CommandMenuForMobile } from '@/command-menu/components/CommandMenuForMobile';
import { SidePanelForDesktop } from '@/side-panel/components/SidePanelForDesktop';
import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKeys';
import { PageBody } from '@/ui/layout/page/components/PageBody';
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { useIsMobile } from 'twenty-ui/utilities';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type MainContainerLayoutWithSidePanelProps = {
type MainContainerLayoutProps = {
children: ReactNode;
};
@@ -54,20 +51,15 @@ const StyledPageBodyForMobileContainer = styled.div`
}
`;
export const MainContainerLayoutWithSidePanel = ({
children,
}: MainContainerLayoutWithSidePanelProps) => {
export const MainContainerLayout = ({ children }: MainContainerLayoutProps) => {
const isMobile = useIsMobile();
useCommandMenuHotKeys();
if (isMobile) {
return (
<StyledMainContainerLayoutForMobile>
<StyledPageBodyForMobileContainer>
<PageBody>{children}</PageBody>
</StyledPageBodyForMobileContainer>
<CommandMenuForMobile />
</StyledMainContainerLayoutForMobile>
);
}
@@ -77,7 +69,6 @@ export const MainContainerLayoutWithSidePanel = ({
<StyledPageBodyForDesktopContainer>
<PageBody>{children}</PageBody>
</StyledPageBodyForDesktopContainer>
<SidePanelForDesktop />
</StyledMainContainerLayoutForDesktop>
);
};
@@ -1,21 +1,16 @@
import { styled } from '@linaria/react';
import { ObjectOptionsDropdown } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdown';
import { RecordBoardContainer } from '@/object-record/record-board/components/RecordBoardContainer';
import { RecordIndexTableContainer } from '@/object-record/record-index/components/RecordIndexTableContainer';
import { RecordIndexViewBarEffect } from '@/object-record/record-index/components/RecordIndexViewBarEffect';
import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { InformationBannerWrapper } from '@/information-banner/components/InformationBannerWrapper';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider';
import { RecordIndexCalendarContainer } from '@/object-record/record-index/components/RecordIndexCalendarContainer';
import { RecordIndexEmptyStateNotShared } from '@/object-record/record-index/components/RecordIndexEmptyStateNotShared';
import { RecordIndexFiltersToContextStoreEffect } from '@/object-record/record-index/components/RecordIndexFiltersToContextStoreEffect';
import { useHasCurrentViewNonReadableFields } from '@/object-record/record-index/hooks/useHasCurrentViewNonReadableFields';
import { ViewBar } from '@/views/components/ViewBar';
import { ViewType } from '@/views/types/ViewType';
import { themeCssVariables } from 'twenty-ui/theme-constants';
@@ -24,7 +19,6 @@ const StyledContainer = styled.div`
flex-direction: column;
height: 100%;
overflow: hidden;
width: 100%;
`;
@@ -38,67 +32,43 @@ const StyledContainerWithPadding = styled.div`
export const RecordIndexContainer = () => {
const recordIndexViewType = useAtomStateValue(recordIndexViewTypeState);
const {
objectNamePlural,
recordIndexId,
objectMetadataItem,
objectNameSingular,
} = useRecordIndexContextOrThrow();
const { recordIndexId, objectMetadataItem, objectNameSingular } =
useRecordIndexContextOrThrow();
const { hasCurrentViewNonReadableFields, nonReadableViewFieldInfo } =
useHasCurrentViewNonReadableFields(objectMetadataItem);
return (
<>
<StyledContainer>
<InformationBannerWrapper />
<SpreadsheetImportProvider>
<ViewBar
isReadOnly={hasCurrentViewNonReadableFields}
viewBarId={recordIndexId}
optionsDropdownButton={
<ObjectOptionsDropdown
recordIndexId={recordIndexId}
objectMetadataItem={objectMetadataItem}
viewType={recordIndexViewType ?? ViewType.TABLE}
<StyledContainer>
{hasCurrentViewNonReadableFields ? (
<RecordIndexEmptyStateNotShared
nonReadableViewFieldInfo={nonReadableViewFieldInfo}
/>
) : (
<>
<RecordIndexFiltersToContextStoreEffect />
{recordIndexViewType === ViewType.TABLE && (
<RecordIndexTableContainer recordTableId={recordIndexId} />
)}
{recordIndexViewType === ViewType.KANBAN && (
<StyledContainerWithPadding>
<RecordBoardContainer
recordBoardId={recordIndexId}
viewBarId={recordIndexId}
objectNameSingular={objectNameSingular}
/>
}
/>
<RecordIndexViewBarEffect
objectNamePlural={objectNamePlural}
viewBarId={recordIndexId}
/>
</SpreadsheetImportProvider>
{hasCurrentViewNonReadableFields ? (
<RecordIndexEmptyStateNotShared
nonReadableViewFieldInfo={nonReadableViewFieldInfo}
/>
) : (
<>
<RecordIndexFiltersToContextStoreEffect />
{recordIndexViewType === ViewType.TABLE && (
<RecordIndexTableContainer recordTableId={recordIndexId} />
)}
{recordIndexViewType === ViewType.KANBAN && (
<StyledContainerWithPadding>
<RecordBoardContainer
recordBoardId={recordIndexId}
viewBarId={recordIndexId}
objectNameSingular={objectNameSingular}
/>
</StyledContainerWithPadding>
)}
{recordIndexViewType === ViewType.CALENDAR && (
<StyledContainerWithPadding>
<RecordIndexCalendarContainer
recordCalendarInstanceId={recordIndexId}
viewBarInstanceId={recordIndexId}
/>
</StyledContainerWithPadding>
)}
</>
)}
</StyledContainer>
</>
</StyledContainerWithPadding>
)}
{recordIndexViewType === ViewType.CALENDAR && (
<StyledContainerWithPadding>
<RecordIndexCalendarContainer
recordCalendarInstanceId={recordIndexId}
viewBarInstanceId={recordIndexId}
/>
</StyledContainerWithPadding>
)}
</>
)}
</StyledContainer>
);
};
@@ -3,7 +3,7 @@ import { RecordIndexContextProvider } from '@/object-record/record-index/context
import { getCommandMenuIdFromRecordIndexId } from '@/command-menu-item/utils/getCommandMenuIdFromRecordIndexId';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { MainContainerLayoutWithSidePanel } from '@/object-record/components/MainContainerLayoutWithSidePanel';
import { RecordIndexViewBar } from '@/object-record/record-index/components/RecordIndexViewBar';
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { lastShowPageRecordIdState } from '@/object-record/record-field/ui/states/lastShowPageRecordId';
@@ -16,6 +16,7 @@ import { useHandleIndexIdentifierClick } from '@/object-record/record-index/hook
import { useRecordIndexFieldMetadataDerivedStates } from '@/object-record/record-index/hooks/useRecordIndexFieldMetadataDerivedStates';
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS } from '@/ui/utilities/drag-select/constants/RecordIndecDragSelectBoundaryClass';
import { PageCardLayout } from '@/ui/layout/page/components/PageCardLayout';
import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
import { styled } from '@linaria/react';
@@ -91,8 +92,12 @@ export const RecordIndexContainerGater = () => {
}}
>
<PageTitle title={objectMetadataItem.labelPlural} />
<RecordIndexPageHeader />
<MainContainerLayoutWithSidePanel>
<PageCardLayout
header={<RecordIndexPageHeader />}
secondaryBar={
hasObjectReadPermissions ? <RecordIndexViewBar /> : undefined
}
>
<StyledIndexContainer
className={RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS}
>
@@ -105,7 +110,7 @@ export const RecordIndexContainerGater = () => {
<RecordIndexEmptyStateNotShared />
)}
</StyledIndexContainer>
</MainContainerLayoutWithSidePanel>
</PageCardLayout>
</CommandMenuComponentInstanceContext.Provider>
</RecordComponentInstanceContextsWrapper>
<RecordIndexLoadBaseOnContextStoreEffect />
@@ -1,5 +1,4 @@
import { RecordIndexCommandMenu } from '@/command-menu-item/components/RecordIndexCommandMenu';
import { SidePanelToggleButton } from '@/side-panel/components/SidePanelToggleButton';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
@@ -7,7 +6,8 @@ import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/st
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { RecordIndexPageHeaderIcon } from '@/object-record/record-index/components/RecordIndexPageHeaderIcon';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { PageHeader } from '@/ui/layout/page/components/PageHeader';
import { SidePanelToggleButton } from '@/side-panel/components/SidePanelToggleButton';
import { PageCardHeader } from '@/ui/layout/page/components/PageCardHeader';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
@@ -68,18 +68,19 @@ export const RecordIndexPageHeader = () => {
);
return (
<PageHeader
title={pageHeaderTitle}
Icon={() => (
<PageCardHeader
icon={
<RecordIndexPageHeaderIcon objectMetadataItem={objectMetadataItem} />
)}
>
{isDefined(contextStoreCurrentViewId) && (
<>
<RecordIndexCommandMenu />
{!isLayoutCustomizationModeEnabled && <SidePanelToggleButton />}
</>
)}
</PageHeader>
}
title={pageHeaderTitle}
actionButton={
isDefined(contextStoreCurrentViewId) ? (
<>
<RecordIndexCommandMenu />
{!isLayoutCustomizationModeEnabled && <SidePanelToggleButton />}
</>
) : undefined
}
/>
);
};
@@ -1,8 +1,77 @@
import { PageContainer } from '@/ui/layout/page/components/PageContainer';
import { PageContentSkeletonLoader } from '~/loading/components/PageContentSkeletonLoader';
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { PageCardHeader } from '@/ui/layout/page/components/PageCardHeader';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
export const RecordIndexSkeletonLoader = () => (
<PageContainer>
<PageContentSkeletonLoader />
</PageContainer>
);
const StyledCard = styled.div`
background: ${themeCssVariables.background.primary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
width: 100%;
`;
const StyledSecondaryBar = styled.div`
align-items: center;
border-bottom: 1px solid ${themeCssVariables.border.color.light};
box-sizing: border-box;
display: flex;
flex-shrink: 0;
justify-content: space-between;
min-height: ${themeCssVariables.spacing[10]};
padding: 0 ${themeCssVariables.spacing[3]};
`;
const StyledBody = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[3]};
`;
export const RecordIndexSkeletonLoader = () => {
const { theme } = useContext(ThemeContext);
return (
<StyledCard>
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<PageCardHeader
icon={<Skeleton width={20} height={20} />}
title={
<Skeleton
width={120}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
}
/>
<StyledSecondaryBar>
<Skeleton
width={120}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
<Skeleton
width={180}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
</StyledSecondaryBar>
<StyledBody>
<Skeleton
count={8}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.l}
/>
</StyledBody>
</SkeletonTheme>
</StyledCard>
);
};
@@ -0,0 +1,39 @@
import { ObjectOptionsDropdown } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdown';
import { RecordIndexViewBarEffect } from '@/object-record/record-index/components/RecordIndexViewBarEffect';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useHasCurrentViewNonReadableFields } from '@/object-record/record-index/hooks/useHasCurrentViewNonReadableFields';
import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState';
import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { ViewBar } from '@/views/components/ViewBar';
import { ViewType } from '@/views/types/ViewType';
export const RecordIndexViewBar = () => {
const recordIndexViewType = useAtomStateValue(recordIndexViewTypeState);
const { objectNamePlural, recordIndexId, objectMetadataItem } =
useRecordIndexContextOrThrow();
const { hasCurrentViewNonReadableFields } =
useHasCurrentViewNonReadableFields(objectMetadataItem);
return (
<SpreadsheetImportProvider>
<ViewBar
isReadOnly={hasCurrentViewNonReadableFields}
viewBarId={recordIndexId}
optionsDropdownButton={
<ObjectOptionsDropdown
recordIndexId={recordIndexId}
objectMetadataItem={objectMetadataItem}
viewType={recordIndexViewType ?? ViewType.TABLE}
/>
}
/>
<RecordIndexViewBarEffect
objectNamePlural={objectNamePlural}
viewBarId={recordIndexId}
/>
</SpreadsheetImportProvider>
);
};
@@ -1,20 +1,17 @@
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { SettingsPageHeader } from '@/settings/components/layout/SettingsPageHeader';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { PageCardHeader } from '@/ui/layout/page/components/PageCardHeader';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledRoot = styled.div<{ isMobile: boolean }>`
const StyledRoot = styled.div`
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
padding: ${({ isMobile }) =>
isMobile ? themeCssVariables.spacing[1] : themeCssVariables.spacing[2]};
`;
const StyledCard = styled.div`
@@ -31,18 +28,17 @@ const StyledCard = styled.div`
`;
export const SettingsSkeletonLoader = () => {
const isMobile = useIsMobile();
const { theme } = useContext(ThemeContext);
return (
<StyledRoot isMobile={isMobile}>
<StyledRoot>
<StyledCard>
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<SettingsPageHeader
<PageCardHeader
links={[
{
children: (
@@ -1,15 +1,9 @@
import { CommandMenuForMobile } from '@/command-menu/components/CommandMenuForMobile';
import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKeys';
import { InformationBannerWrapper } from '@/information-banner/components/InformationBannerWrapper';
import { SettingsPageHeader } from '@/settings/components/layout/SettingsPageHeader';
import { SettingsSecondaryBar } from '@/settings/components/layout/SettingsSecondaryBar';
import { SidePanelForDesktop } from '@/side-panel/components/SidePanelForDesktop';
import { PageCardHeader } from '@/ui/layout/page/components/PageCardHeader';
import { PageCardLayout } from '@/ui/layout/page/components/PageCardLayout';
import { type BreadcrumbProps } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { styled } from '@linaria/react';
import { type JSX, type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type SettingsPageLayoutProps = {
links: BreadcrumbProps['links'];
@@ -20,44 +14,6 @@ type SettingsPageLayoutProps = {
tag?: JSX.Element;
};
const StyledRoot = styled.div<{ isMobile: boolean }>`
display: flex;
flex: 1;
flex-direction: row;
min-height: 0;
min-width: 0;
padding: ${({ isMobile }) =>
isMobile ? themeCssVariables.spacing[1] : themeCssVariables.spacing[2]};
`;
const StyledMainCardWrapper = styled.div`
display: flex;
flex: 1 1 0;
min-width: 0;
width: 0;
`;
const StyledCard = styled.div`
background: ${themeCssVariables.background.primary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
width: 100%;
`;
const StyledBodyContent = styled.div`
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
width: 100%;
`;
export const SettingsPageLayout = ({
links,
title,
@@ -65,31 +21,22 @@ export const SettingsPageLayout = ({
secondaryBar,
children,
tag,
}: SettingsPageLayoutProps) => {
const isMobile = useIsMobile();
useCommandMenuHotKeys();
return (
<StyledRoot isMobile={isMobile}>
<StyledMainCardWrapper>
<StyledCard>
<SettingsPageHeader
links={links}
title={title}
tag={tag}
actionButton={actionButton}
/>
{isDefined(secondaryBar) && (
<SettingsSecondaryBar>{secondaryBar}</SettingsSecondaryBar>
)}
<StyledBodyContent>
<InformationBannerWrapper />
{children}
</StyledBodyContent>
</StyledCard>
</StyledMainCardWrapper>
{isMobile ? <CommandMenuForMobile /> : <SidePanelForDesktop />}
</StyledRoot>
);
};
}: SettingsPageLayoutProps) => (
<PageCardLayout
header={
<PageCardHeader
links={links}
title={title}
tag={tag}
actionButton={actionButton}
/>
}
secondaryBar={
isDefined(secondaryBar) ? (
<SettingsSecondaryBar>{secondaryBar}</SettingsSecondaryBar>
) : undefined
}
>
{children}
</PageCardLayout>
);
@@ -145,7 +145,7 @@ export const SidePanelToggleButton = () => {
dataClickOutsideId={PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID}
dataTestId="page-header-side-panel-button"
size={isMobile ? 'medium' : 'small'}
variant="secondary"
variant="tertiary"
accent="default"
hotkeys={[getOsControlSymbol(), 'K']}
ariaLabel={ariaLabel}
@@ -0,0 +1,40 @@
import { CommandMenuForMobile } from '@/command-menu/components/CommandMenuForMobile';
import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKeys';
import { SidePanelForDesktop } from '@/side-panel/components/SidePanelForDesktop';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { styled } from '@linaria/react';
import { Outlet } from 'react-router-dom';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledRow = styled.div<{ isMobile: boolean }>`
display: flex;
flex: 1;
flex-direction: row;
min-height: 0;
min-width: 0;
padding: ${({ isMobile }) =>
isMobile ? themeCssVariables.spacing[1] : themeCssVariables.spacing[2]};
`;
const StyledContent = styled.div`
display: flex;
flex: 1 1 0;
min-height: 0;
min-width: 0;
overflow: hidden;
`;
export const MainAppLayoutWithSidePanel = () => {
const isMobile = useIsMobile();
useCommandMenuHotKeys();
return (
<StyledRow isMobile={isMobile}>
<StyledContent>
<Outlet />
</StyledContent>
{isMobile ? <CommandMenuForMobile /> : <SidePanelForDesktop />}
</StyledRow>
);
};
@@ -11,23 +11,22 @@ import { type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type SettingsPageHeaderProps = {
links: BreadcrumbProps['links'];
type PageCardHeaderProps = {
links?: BreadcrumbProps['links'];
breadcrumb?: ReactNode;
icon?: ReactNode;
title?: ReactNode;
tag?: ReactNode;
actionButton?: ReactNode;
};
// minmax(0, 1fr) side tracks (not 1fr) let a long breadcrumb truncate instead of
// pushing the centered title off its shared axis with the tabs and body.
const StyledHeader = styled.div`
align-items: center;
background-color: ${themeCssVariables.background.secondary};
border-bottom: 1px solid ${themeCssVariables.border.color.medium};
box-sizing: border-box;
display: grid;
display: flex;
gap: ${themeCssVariables.spacing[2]};
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
min-height: ${SIDE_PANEL_TOP_BAR_HEIGHT}px;
padding: 0 ${themeCssVariables.spacing[3]};
width: 100%;
@@ -36,7 +35,7 @@ const StyledHeader = styled.div`
const StyledLeft = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[1]};
gap: ${themeCssVariables.spacing[2]};
min-width: 0;
overflow: hidden;
`;
@@ -49,23 +48,25 @@ const StyledTitle = styled.div`
font-weight: ${themeCssVariables.font.weight.semiBold};
gap: ${themeCssVariables.spacing[2]};
min-width: 0;
text-align: center;
`;
const StyledRight = styled.div`
align-items: center;
display: flex;
flex: 1;
gap: ${themeCssVariables.spacing[2]};
justify-content: flex-end;
min-width: 0;
`;
export const SettingsPageHeader = ({
export const PageCardHeader = ({
links,
breadcrumb,
icon,
title,
tag,
actionButton,
}: SettingsPageHeaderProps) => {
}: PageCardHeaderProps) => {
const isMobile = useIsMobile();
const isNavigationDrawerExpanded = useNavigationDrawerExpanded();
@@ -75,12 +76,18 @@ export const SettingsPageHeader = ({
{!isNavigationDrawerExpanded && (
<NavigationDrawerCollapseButton direction="right" />
)}
<Breadcrumb links={links} />
{isDefined(breadcrumb)
? breadcrumb
: isDefined(links) && <Breadcrumb links={links} />}
{!isMobile &&
(isDefined(icon) || isDefined(title) || isDefined(tag)) && (
<StyledTitle>
{icon}
{isDefined(title) && title}
{tag}
</StyledTitle>
)}
</StyledLeft>
<StyledTitle>
{!isMobile && isDefined(title) && title}
{!isMobile && tag}
</StyledTitle>
<StyledRight>{actionButton}</StyledRight>
</StyledHeader>
);
@@ -0,0 +1,68 @@
import { InformationBannerWrapper } from '@/information-banner/components/InformationBannerWrapper';
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
type PageCardLayoutProps = {
header: ReactNode;
secondaryBar?: ReactNode;
children: ReactNode;
};
const StyledRoot = styled.div`
display: flex;
flex: 1;
flex-direction: row;
min-height: 0;
min-width: 0;
`;
const StyledMainCardWrapper = styled.div`
display: flex;
flex: 1 1 0;
min-width: 0;
width: 0;
`;
const StyledCard = styled.div`
background: ${themeCssVariables.background.primary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
box-sizing: border-box;
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
width: 100%;
`;
const StyledBodyContent = styled.div`
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
width: 100%;
`;
export const PageCardLayout = ({
header,
secondaryBar,
children,
}: PageCardLayoutProps) => {
return (
<StyledRoot>
<StyledMainCardWrapper>
<StyledCard>
{header}
{isDefined(secondaryBar) && secondaryBar}
<StyledBodyContent>
<InformationBannerWrapper />
{children}
</StyledBodyContent>
</StyledCard>
</StyledMainCardWrapper>
</StyledRoot>
);
};
@@ -7,13 +7,12 @@ import { TimelineActivityContext } from '@/activities/timeline-activities/contex
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState';
import { MainContainerLayoutWithSidePanel } from '@/object-record/components/MainContainerLayoutWithSidePanel';
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
import { PageLayoutRecordPageRenderer } from '@/object-record/record-show/components/PageLayoutRecordPageRenderer';
import { RecordShowPageSSESubscribeEffect } from '@/object-record/record-show/components/RecordShowPageSSESubscribeEffect';
import { useRecordShowPage } from '@/object-record/record-show/hooks/useRecordShowPage';
import { computeRecordShowComponentInstanceId } from '@/object-record/record-show/utils/computeRecordShowComponentInstanceId';
import { PageContainer } from '@/ui/layout/page/components/PageContainer';
import { PageCardLayout } from '@/ui/layout/page/components/PageCardLayout';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { RecordShowPageHeader } from '~/pages/object-record/RecordShowPageHeader';
import { RecordShowPageTitle } from '~/pages/object-record/RecordShowPageTitle';
@@ -46,38 +45,39 @@ export const RecordShowPage = () => {
<CommandMenuComponentInstanceContext.Provider
value={{ instanceId: recordShowComponentInstanceId }}
>
<PageContainer>
<RecordShowPageTitle
objectNameSingular={objectNameSingular}
objectRecordId={objectRecordId}
/>
<RecordShowPageHeader
objectNameSingular={objectNameSingular}
objectRecordId={objectRecordId}
>
<RecordShowCommandMenu />
{!isLayoutCustomizationModeEnabled && <SidePanelToggleButton />}
</RecordShowPageHeader>
<MainContainerLayoutWithSidePanel>
<TimelineActivityContext.Provider
value={{
recordId: objectRecordId,
}}
<RecordShowPageTitle
objectNameSingular={objectNameSingular}
objectRecordId={objectRecordId}
/>
<PageCardLayout
header={
<RecordShowPageHeader
objectNameSingular={objectNameSingular}
objectRecordId={objectRecordId}
>
<PageLayoutRecordPageRenderer
targetRecordIdentifier={{
id: objectRecordId,
targetObjectNameSingular: objectNameSingular,
}}
isInSidePanel={false}
/>
<RecordShowPageSSESubscribeEffect
objectNameSingular={objectNameSingular}
recordId={objectRecordId}
/>
</TimelineActivityContext.Provider>
</MainContainerLayoutWithSidePanel>
</PageContainer>
<RecordShowCommandMenu />
{!isLayoutCustomizationModeEnabled && <SidePanelToggleButton />}
</RecordShowPageHeader>
}
>
<TimelineActivityContext.Provider
value={{
recordId: objectRecordId,
}}
>
<PageLayoutRecordPageRenderer
targetRecordIdentifier={{
id: objectRecordId,
targetObjectNameSingular: objectNameSingular,
}}
isInSidePanel={false}
/>
<RecordShowPageSSESubscribeEffect
objectNameSingular={objectNameSingular}
recordId={objectRecordId}
/>
</TimelineActivityContext.Provider>
</PageCardLayout>
</CommandMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</RecordComponentInstanceContextsWrapper>
@@ -1,7 +1,7 @@
import { getObjectMetadataIdentifierFields } from '@/object-metadata/utils/getObjectMetadataIdentifierFields';
import { ObjectRecordShowPageBreadcrumb } from '@/object-record/record-show/components/ObjectRecordShowPageBreadcrumb';
import { useRecordShowPagePagination } from '@/object-record/record-show/hooks/useRecordShowPagePagination';
import { PageHeader } from '@/ui/layout/page/components/PageHeader';
import { PageCardHeader } from '@/ui/layout/page/components/PageCardHeader';
export const RecordShowPageHeader = ({
objectNameSingular,
@@ -21,8 +21,8 @@ export const RecordShowPageHeader = ({
getObjectMetadataIdentifierFields({ objectMetadataItem });
return (
<PageHeader
title={
<PageCardHeader
breadcrumb={
<ObjectRecordShowPageBreadcrumb
objectNameSingular={objectNameSingular}
objectRecordId={objectRecordId}
@@ -30,8 +30,7 @@ export const RecordShowPageHeader = ({
labelIdentifierFieldMetadataItem={labelIdentifierFieldMetadataItem}
/>
}
>
{children}
</PageHeader>
actionButton={children}
/>
);
};
@@ -3,7 +3,7 @@ import { useParams } from 'react-router-dom';
import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { MainContainerLayoutWithSidePanel } from '@/object-record/components/MainContainerLayoutWithSidePanel';
import { MainContainerLayout } from '@/object-record/components/MainContainerLayout';
import { PageLayoutRenderer } from '@/page-layout/components/PageLayoutRenderer';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { PageContainer } from '@/ui/layout/page/components/PageContainer';
@@ -34,9 +34,9 @@ export const StandalonePageLayoutPage = () => {
isInSidePanel: false,
}}
>
<MainContainerLayoutWithSidePanel>
<MainContainerLayout>
<PageLayoutRenderer pageLayoutId={pageLayoutId} />
</MainContainerLayoutWithSidePanel>
</MainContainerLayout>
</LayoutRenderingProvider>
</CommandMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>