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:
+56
-56
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+2
-2
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-8
@@ -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`,
|
||||
|
||||
+9
-9
@@ -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}
|
||||
|
||||
-114
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user