fix(front): settings skeleton, app-detail header & empty favorites (#21209)

Three small post-redesign UI fixes. Each is an independent commit, so
they can be split into separate PRs if preferred.

## 1. Settings loading skeleton — match the rounded-card layout

The redesign (#21131) moved settings chrome into a rounded card
(`SettingsPageLayout`: bordered header with breadcrumb + centered title,
optional secondary bar, 760px body), but `SettingsSkeletonLoader` still
rendered the old flat `PageHeader` + `PageBody` — so pages painted as a
full-width flat bar then snapped into the card.

- `SettingsSkeletonLoader` now reproduces the card and **reuses the real
`SettingsPageHeader` + `SettingsPageContainer`**, so the frame aligns by
construction; the card CSS is replicated (not `SettingsPageLayout`) to
avoid the layout's side effects (hotkeys, side panel, info banner).
- It's **composed with `SettingsSectionSkeletonLoader`** so the loading
body is identical whether or not chrome is present. Rule: no chrome on
screen yet → full-page skeleton; chrome already on screen → body-only
`SettingsSectionSkeletonLoader` (the admin Enterprise tab now uses it,
matching its sibling tabs). A short comment on each component documents
this.

## 2. Application detail header — pass a plain title

`SettingsApplicationDetails` / `SettingsAvailableApplicationDetails`
passed a custom `SettingsApplicationDetailTitle` (avatar + name +
multi-line description, fixed width) into `SettingsPageLayout`'s
**centered single-line title slot**, which broke the header. They now
pass the app's display name like every other page. The available-app
"unlisted" notice moves into the body as a reusable `InlineBanner`; the
now-unused `SettingsApplicationDetailTitle` is removed.

## 3. Navigation — hide Favorites when empty

Always rendering the Favorites section (#21087) left a stray "Favorites"
title above Workspace for users with no favorites. It now renders only
when at least one favorite exists (redundant per-child guards dropped).
Note: the "+ add favorite" entry point therefore appears once you have
≥1 favorite; the first favorite is created from a record/view as before.

## Verification
- `nx typecheck twenty-front`  · `oxlint` + `oxfmt --check` on changed
files 
- i18n catalogs intentionally untouched — handled by the repo's separate
i18n pipeline.
This commit is contained in:
Félix Malfait
2026-06-04 09:32:36 +02:00
committed by GitHub
parent e99116b0a5
commit a3a44c8315
6 changed files with 122 additions and 227 deletions
@@ -91,8 +91,6 @@ export const FavoritesSection = () => {
const handleAddFavorite = (event?: React.MouseEvent) => {
event?.stopPropagation();
// Expansion is gated on items existing, so this stays collapsed if the user
// cancels and reveals the favorite as soon as it is added.
openNavigationSection();
setNavigationMenuItemEditSection('favorite');
setPendingInsertionNavigationMenuItem(null);
@@ -119,10 +117,14 @@ export const FavoritesSection = () => {
[deleteManyNavigationMenuItems],
);
if (topLevelItems.length === 0) {
return null;
}
return (
<NavigationMenuItemSection
title={t`Favorites`}
isOpen={topLevelItems.length > 0 && isNavigationSectionOpen}
isOpen={isNavigationSectionOpen}
onToggle={toggleNavigationSection}
rightIcon={
<LightIconButton
@@ -132,66 +134,64 @@ export const FavoritesSection = () => {
/>
}
>
{topLevelItems.length > 0 && (
<StyledList>
{topLevelItems.map((item, index) => (
<StyledListItemRow key={item.id}>
{index === 0 ? (
<NavigationMenuItemDroppableSlot
droppableId={ORPHAN_DROPPABLE_ID}
index={0}
disabled={favoritesDropDisabled}
>
<NavigationMenuItemOrphanDropTarget
index={0}
compact
sectionId={NavigationSections.FAVORITES}
droppableId={ORPHAN_DROPPABLE_ID}
/>
</NavigationMenuItemDroppableSlot>
) : (
<StyledList>
{topLevelItems.map((item, index) => (
<StyledListItemRow key={item.id}>
{index === 0 ? (
<NavigationMenuItemDroppableSlot
droppableId={ORPHAN_DROPPABLE_ID}
index={0}
disabled={favoritesDropDisabled}
>
<NavigationMenuItemOrphanDropTarget
index={index}
index={0}
compact
sectionId={NavigationSections.FAVORITES}
droppableId={ORPHAN_DROPPABLE_ID}
/>
)}
<NavigationMenuItemSortableItem
id={item.id}
</NavigationMenuItemDroppableSlot>
) : (
<NavigationMenuItemOrphanDropTarget
index={index}
group={ORPHAN_DROPPABLE_ID}
disabled={favoritesDropDisabled}
>
<NavigationMenuItemDisplay
item={item}
isEditInPlace={isNavigationMenuItemFolder(item)}
isDragging={isDragging}
folderChildrenById={folderChildrenById}
folderCount={folderCount}
rightOptions={
isNavigationMenuItemFolder(item)
? undefined
: makeRightOptions(item)
}
/>
</NavigationMenuItemSortableItem>
</StyledListItemRow>
))}
<NavigationMenuItemDroppableSlot
droppableId={ORPHAN_DROPPABLE_ID}
compact
sectionId={NavigationSections.FAVORITES}
droppableId={ORPHAN_DROPPABLE_ID}
/>
)}
<NavigationMenuItemSortableItem
id={item.id}
index={index}
group={ORPHAN_DROPPABLE_ID}
disabled={favoritesDropDisabled}
>
<NavigationMenuItemDisplay
item={item}
isEditInPlace={isNavigationMenuItemFolder(item)}
isDragging={isDragging}
folderChildrenById={folderChildrenById}
folderCount={folderCount}
rightOptions={
isNavigationMenuItemFolder(item)
? undefined
: makeRightOptions(item)
}
/>
</NavigationMenuItemSortableItem>
</StyledListItemRow>
))}
<NavigationMenuItemDroppableSlot
droppableId={ORPHAN_DROPPABLE_ID}
index={topLevelItems.length}
disabled={favoritesDropDisabled}
>
<NavigationMenuItemOrphanDropTarget
index={topLevelItems.length}
disabled={favoritesDropDisabled}
>
<NavigationMenuItemOrphanDropTarget
index={topLevelItems.length}
compact
sectionId={NavigationSections.FAVORITES}
droppableId={ORPHAN_DROPPABLE_ID}
/>
</NavigationMenuItemDroppableSlot>
</StyledList>
)}
compact
sectionId={NavigationSections.FAVORITES}
droppableId={ORPHAN_DROPPABLE_ID}
/>
</NavigationMenuItemDroppableSlot>
</StyledList>
</NavigationMenuItemSection>
);
};
@@ -5,7 +5,7 @@ import { SettingsAdminConfigVariables } from '@/settings/admin-panel/config-vari
import { SETTINGS_ADMIN_TABS } from '@/settings/admin-panel/constants/SettingsAdminTabs';
import { SETTINGS_ADMIN_TABS_ID } from '@/settings/admin-panel/constants/SettingsAdminTabsId';
import { SettingsAdminHealthStatus } from '@/settings/admin-panel/health-status/components/SettingsAdminHealthStatus';
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { lazy, Suspense } from 'react';
@@ -34,7 +34,7 @@ export const SettingsAdminTabContent = () => {
return <SettingsAdminHealthStatus />;
case SETTINGS_ADMIN_TABS.ENTERPRISE:
return (
<Suspense fallback={<SettingsSkeletonLoader />}>
<Suspense fallback={<SettingsSectionSkeletonLoader />}>
<SettingsEnterprise isAdminPanelTab />
</Suspense>
);
@@ -1,54 +1,70 @@
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { PageBody } from '@/ui/layout/page/components/PageBody';
import { PageHeader } from '@/ui/layout/page/components/PageHeader';
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 { styled } from '@linaria/react';
import { useContext } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledContainer = styled.div`
const StyledRoot = styled.div<{ isMobile: boolean }>`
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
padding: ${({ isMobile }) =>
isMobile ? themeCssVariables.spacing[1] : themeCssVariables.spacing[2]};
`;
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 StyledTitleLoaderContainer = styled.div`
margin: ${themeCssVariables.spacing[8]} ${themeCssVariables.spacing[8]}
${themeCssVariables.spacing[2]};
`;
export const SettingsSkeletonLoader = () => {
const isMobile = useIsMobile();
const { theme } = useContext(ThemeContext);
return (
<StyledContainer>
<PageHeader
title={
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<Skeleton
height={SKELETON_LOADER_HEIGHT_SIZES.standard.m}
width={120}
/>{' '}
</SkeletonTheme>
}
/>
<PageBody>
<StyledTitleLoaderContainer>
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<Skeleton
height={SKELETON_LOADER_HEIGHT_SIZES.standard.m}
width={200}
/>
</SkeletonTheme>
</StyledTitleLoaderContainer>
</PageBody>
</StyledContainer>
<StyledRoot isMobile={isMobile}>
<StyledCard>
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<SettingsPageHeader
links={[
{
children: (
<Skeleton
width={64}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
),
},
]}
title={
<Skeleton
width={120}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.s}
/>
}
/>
<SettingsPageContainer>
<SettingsSectionSkeletonLoader />
</SettingsPageContainer>
</SkeletonTheme>
</StyledCard>
</StyledRoot>
);
};
@@ -41,7 +41,6 @@ import {
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { SettingsApplicationDetailTitle } from '~/pages/settings/applications/components/SettingsApplicationDetailTitle';
import { CUSTOM_APPLICATION_ILLUSTRATIONS } from '~/pages/settings/applications/constants/CustomApplicationIllustrations';
import { STANDARD_APPLICATION_ILLUSTRATIONS } from '~/pages/settings/applications/constants/StandardApplicationIllustrations';
import { useFindApplicationConnectionProviders } from '~/pages/settings/applications/hooks/useFindApplicationConnectionProviders';
@@ -326,13 +325,7 @@ export const SettingsApplicationDetails = () => {
return (
<CurrentApplicationContext.Provider value={application?.id ?? null}>
<SettingsPageLayout
title={
<SettingsApplicationDetailTitle
displayName={displayName}
description={description}
applicationId={application?.id}
/>
}
title={displayName}
links={[
{
children: t`Workspace`,
@@ -19,12 +19,14 @@ import {
IconBook,
IconBox,
IconCommand,
IconEyeOff,
IconGraph,
IconInfoCircle,
IconLego,
IconListDetails,
IconLock,
IconShield,
InlineBanner,
} from 'twenty-ui/display';
import {
ApplicationRegistrationSourceType,
@@ -32,7 +34,6 @@ import {
FindOneApplicationByUniversalIdentifierDocument,
PermissionFlagType,
} from '~/generated-metadata/graphql';
import { SettingsApplicationDetailTitle } from '~/pages/settings/applications/components/SettingsApplicationDetailTitle';
import { SettingsApplicationDetailAboutTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailAboutTab';
import { SettingsApplicationDetailContentTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailContentTab';
import { SettingsApplicationPermissionsTab } from '~/pages/settings/applications/tabs/SettingsApplicationPermissionsTab';
@@ -283,16 +284,15 @@ export const SettingsAvailableApplicationDetails = () => {
},
{ children: displayName },
]}
title={
<SettingsApplicationDetailTitle
displayName={displayName}
description={description}
applicationId={application?.id}
isUnlisted={isUnlisted}
/>
}
title={displayName}
>
<SettingsPageContainer>
{isUnlisted && (
<InlineBanner
LeftIcon={IconEyeOff}
message={t`Application not listed on the marketplace. It was shared via a direct link`}
/>
)}
<TabList
tabs={tabs}
componentInstanceId={AVAILABLE_APPLICATION_DETAIL_ID}
@@ -1,114 +0,0 @@
import { OBJECT_SETTINGS_WIDTH } from '@/settings/data-model/constants/ObjectSettings';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { Avatar, IconEyeOff } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { getApplicationDescriptionSummary } from '~/pages/settings/applications/utils/getApplicationDescriptionSummary';
import { useApplicationChipData } from '@/applications/hooks/useApplicationChipData';
type SettingsApplicationDetailTitleProps = {
displayName: string;
description?: string;
logoUrl?: string;
applicationId?: string;
applicationName?: string;
universalIdentifier?: string;
isUnlisted?: boolean;
};
const StyledTitleContainer = styled.div`
width: ${() => {
return OBJECT_SETTINGS_WIDTH + 'px';
}};
`;
const StyledHeader = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[4]};
justify-content: space-between;
`;
const StyledHeaderLeft = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledHeaderTop = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[1]};
`;
const StyledAppName = styled.div`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.lg};
font-weight: ${themeCssVariables.font.weight.semiBold};
`;
const StyledAppDescription = styled.div`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.regular};
line-height: ${themeCssVariables.text.lineHeight.lg};
`;
const StyledUnlistedBanner = styled.div`
align-items: center;
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.sm};
color: ${themeCssVariables.font.color.secondary};
display: flex;
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.regular};
gap: ${themeCssVariables.spacing[2]};
justify-content: center;
margin-bottom: ${themeCssVariables.spacing[8]};
padding: ${themeCssVariables.spacing[3]};
`;
export const SettingsApplicationDetailTitle = ({
displayName,
description,
applicationId,
isUnlisted = false,
}: SettingsApplicationDetailTitleProps) => {
const descriptionSummary = getApplicationDescriptionSummary(description);
const { applicationChipData } = useApplicationChipData({
applicationId,
});
return (
<StyledTitleContainer>
{isUnlisted && (
<StyledUnlistedBanner>
<IconEyeOff size={16} />
{t`Application not listed on the marketplace. It was shared via a direct link`}
</StyledUnlistedBanner>
)}
<StyledHeader>
<StyledHeaderLeft>
<StyledHeaderTop>
<Avatar
type="app"
size="lg"
avatarUrl={applicationChipData.logo}
placeholder={applicationChipData.name}
placeholderColorSeed={applicationChipData.seed}
color={applicationChipData.colors?.color}
backgroundColor={applicationChipData.colors?.backgroundColor}
borderColor={applicationChipData.colors?.borderColor}
/>
<StyledAppName>{displayName}</StyledAppName>
</StyledHeaderTop>
{descriptionSummary && (
<StyledAppDescription>{descriptionSummary}</StyledAppDescription>
)}
</StyledHeaderLeft>
</StyledHeader>
</StyledTitleContainer>
);
};