Update record page layout card (#23654)
## Summary - Redesign the data-model Layout card to match the record-page customization design. - Add dedicated light and dark cover assets and preserve the existing customization workflow. - Reuse a dedicated discovery-hero footer across the workspace and record layout cards. - Match the Figma cover, footer, icon, typography, and spacing metrics. ## Before/After <img width="1588" height="720" alt="before-after" src="https://github.com/user-attachments/assets/28be577c-9f1b-40f1-99e2-66eeafbac75b" />
This commit is contained in:
committed by
GitHub
parent
6afc3d33a4
commit
536b91c1dd
@@ -12,12 +12,12 @@ import { Card } from 'twenty-ui/surfaces';
|
|||||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||||
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
import { FeatureFlagKey } from '~/generated-metadata/graphql';
|
||||||
|
|
||||||
const COVER_HEIGHT = 150;
|
const DEFAULT_COVER_HEIGHT = 150;
|
||||||
|
|
||||||
const StyledCoverContainer = styled.div`
|
const StyledCoverContainer = styled.div<{ coverHeight: number }>`
|
||||||
background: ${themeCssVariables.background.secondary};
|
background: ${themeCssVariables.background.secondary};
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
height: ${COVER_HEIGHT}px;
|
height: ${({ coverHeight }) => coverHeight}px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
`;
|
`;
|
||||||
@@ -50,6 +50,7 @@ type SettingsDiscoveryHeroCardProps = {
|
|||||||
darkSrc: string;
|
darkSrc: string;
|
||||||
instanceIdPrefix: string;
|
instanceIdPrefix: string;
|
||||||
tabs: SettingsCustomizeVideoModalTab[];
|
tabs: SettingsCustomizeVideoModalTab[];
|
||||||
|
coverHeight?: number;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
playButtonAriaLabel?: string;
|
playButtonAriaLabel?: string;
|
||||||
};
|
};
|
||||||
@@ -59,6 +60,7 @@ export const SettingsDiscoveryHeroCard = ({
|
|||||||
darkSrc,
|
darkSrc,
|
||||||
instanceIdPrefix,
|
instanceIdPrefix,
|
||||||
tabs,
|
tabs,
|
||||||
|
coverHeight = DEFAULT_COVER_HEIGHT,
|
||||||
footer,
|
footer,
|
||||||
playButtonAriaLabel,
|
playButtonAriaLabel,
|
||||||
}: SettingsDiscoveryHeroCardProps) => {
|
}: SettingsDiscoveryHeroCardProps) => {
|
||||||
@@ -78,7 +80,7 @@ export const SettingsDiscoveryHeroCard = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card rounded>
|
<Card rounded>
|
||||||
<StyledCoverContainer>
|
<StyledCoverContainer coverHeight={coverHeight}>
|
||||||
<StyledImage src={src} alt="" aria-hidden />
|
<StyledImage src={src} alt="" aria-hidden />
|
||||||
{shouldDisplayVideo && (
|
{shouldDisplayVideo && (
|
||||||
<StyledOverlay>
|
<StyledOverlay>
|
||||||
|
|||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
import { styled } from '@linaria/react';
|
||||||
|
import { type ReactNode, useContext } from 'react';
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
import { type IconComponent } from 'twenty-ui/icon';
|
||||||
|
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
|
||||||
|
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||||
|
|
||||||
|
const StyledContent = styled.div`
|
||||||
|
align-items: center;
|
||||||
|
background-color: ${themeCssVariables.background.secondary};
|
||||||
|
display: flex;
|
||||||
|
gap: ${themeCssVariables.spacing[3]};
|
||||||
|
padding: ${themeCssVariables.spacing[4]};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledIconContainer = styled.div`
|
||||||
|
align-items: center;
|
||||||
|
background-color: ${themeCssVariables.background.primary};
|
||||||
|
border: 2px solid ${themeCssVariables.border.color.light};
|
||||||
|
border-radius: ${themeCssVariables.border.radius.sm};
|
||||||
|
display: flex;
|
||||||
|
height: ${themeCssVariables.spacing[8]};
|
||||||
|
justify-content: center;
|
||||||
|
min-width: ${themeCssVariables.spacing[8]};
|
||||||
|
width: ${themeCssVariables.spacing[8]};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledIcon = styled.div`
|
||||||
|
align-items: center;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
transform: rotate(-4deg);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledTextContainer = styled.div`
|
||||||
|
align-items: flex-start;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: ${themeCssVariables.spacing[2]};
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledTitle = styled.div`
|
||||||
|
align-items: center;
|
||||||
|
color: ${themeCssVariables.font.color.primary};
|
||||||
|
display: flex;
|
||||||
|
font-weight: ${themeCssVariables.font.weight.medium};
|
||||||
|
min-height: ${themeCssVariables.spacing[4]};
|
||||||
|
width: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledDescription = styled.div`
|
||||||
|
color: ${themeCssVariables.font.color.secondary};
|
||||||
|
font-size: ${themeCssVariables.font.size.sm};
|
||||||
|
line-height: ${themeCssVariables.text.lineHeight.lg};
|
||||||
|
overflow: hidden;
|
||||||
|
text-box-edge: cap alphabetic;
|
||||||
|
text-box-trim: trim-both;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledActionContainer = styled.div`
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
`;
|
||||||
|
|
||||||
|
type SettingsDiscoveryHeroCardFooterProps = {
|
||||||
|
Icon: IconComponent;
|
||||||
|
title: ReactNode;
|
||||||
|
description?: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SettingsDiscoveryHeroCardFooter = ({
|
||||||
|
Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
}: SettingsDiscoveryHeroCardFooterProps) => {
|
||||||
|
const { theme } = useContext(ThemeContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledContent>
|
||||||
|
<StyledIconContainer>
|
||||||
|
<StyledIcon>
|
||||||
|
<Icon
|
||||||
|
size={theme.icon.size.lg}
|
||||||
|
color={theme.IllustrationIcon.color.gray}
|
||||||
|
stroke={theme.icon.stroke.md}
|
||||||
|
/>
|
||||||
|
</StyledIcon>
|
||||||
|
</StyledIconContainer>
|
||||||
|
<StyledTextContainer>
|
||||||
|
<StyledTitle>{title}</StyledTitle>
|
||||||
|
{description && (
|
||||||
|
<StyledDescription>
|
||||||
|
<OverflowingTextWithTooltip text={description} />
|
||||||
|
</StyledDescription>
|
||||||
|
)}
|
||||||
|
</StyledTextContainer>
|
||||||
|
{isDefined(action) && (
|
||||||
|
<StyledActionContainer>{action}</StyledActionContainer>
|
||||||
|
)}
|
||||||
|
</StyledContent>
|
||||||
|
);
|
||||||
|
};
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
+31
-12
@@ -1,13 +1,11 @@
|
|||||||
import { useContext } from 'react';
|
|
||||||
|
|
||||||
import { styled } from '@linaria/react';
|
import { styled } from '@linaria/react';
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { AppPath } from 'twenty-shared/types';
|
import { AppPath } from 'twenty-shared/types';
|
||||||
import { isDefined } from 'twenty-shared/utils';
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
import { IconAddressBook, IconReload } from 'twenty-ui/icon';
|
import { IconAddressBook, IconPencil, IconReload } from 'twenty-ui/icon';
|
||||||
import { Button } from 'twenty-ui/input';
|
import { Button } from 'twenty-ui/input';
|
||||||
import { Section } from 'twenty-ui/layout';
|
import { Section } from 'twenty-ui/layout';
|
||||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||||
import { H2Title } from 'twenty-ui/typography';
|
import { H2Title } from 'twenty-ui/typography';
|
||||||
|
|
||||||
import { useEnterLayoutCustomizationMode } from '@/layout-customization/hooks/useEnterLayoutCustomizationMode';
|
import { useEnterLayoutCustomizationMode } from '@/layout-customization/hooks/useEnterLayoutCustomizationMode';
|
||||||
@@ -15,7 +13,10 @@ import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/Enriche
|
|||||||
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
|
||||||
import { useResetPageLayoutToDefault } from '@/page-layout/hooks/useResetPageLayoutToDefault';
|
import { useResetPageLayoutToDefault } from '@/page-layout/hooks/useResetPageLayoutToDefault';
|
||||||
import { recordPageLayoutByObjectMetadataIdFamilySelector } from '@/page-layout/states/selectors/recordPageLayoutByObjectMetadataIdFamilySelector';
|
import { recordPageLayoutByObjectMetadataIdFamilySelector } from '@/page-layout/states/selectors/recordPageLayoutByObjectMetadataIdFamilySelector';
|
||||||
import { SettingsCard } from '@/settings/components/SettingsCard';
|
import { SettingsDiscoveryHeroCard } from '@/settings/components/SettingsDiscoveryHeroCard';
|
||||||
|
import { SettingsDiscoveryHeroCardFooter } from '@/settings/components/SettingsDiscoveryHeroCardFooter';
|
||||||
|
import recordPageLayoutCoverDark from '@/settings/data-model/object-details/assets/record-page-layout-cover-dark.png';
|
||||||
|
import recordPageLayoutCoverLight from '@/settings/data-model/object-details/assets/record-page-layout-cover-light.png';
|
||||||
import { ObjectOpenRecordInPicker } from '@/settings/data-model/object-details/components/tabs/ObjectOpenRecordInPicker';
|
import { ObjectOpenRecordInPicker } from '@/settings/data-model/object-details/components/tabs/ObjectOpenRecordInPicker';
|
||||||
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
|
import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag';
|
||||||
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
|
||||||
@@ -32,13 +33,13 @@ const StyledContentContainer = styled.div`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const RESET_PAGE_LAYOUT_MODAL_ID = 'reset-page-layout-to-default-modal';
|
const RESET_PAGE_LAYOUT_MODAL_ID = 'reset-page-layout-to-default-modal';
|
||||||
|
const RECORD_PAGE_LAYOUT_HERO_INSTANCE_ID_PREFIX = 'record-page-layout-hero';
|
||||||
|
|
||||||
type ObjectLayoutProps = {
|
type ObjectLayoutProps = {
|
||||||
objectMetadataItem: EnrichedObjectMetadataItem;
|
objectMetadataItem: EnrichedObjectMetadataItem;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ObjectLayout = ({ objectMetadataItem }: ObjectLayoutProps) => {
|
export const ObjectLayout = ({ objectMetadataItem }: ObjectLayoutProps) => {
|
||||||
const { theme } = useContext(ThemeContext);
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const navigateApp = useNavigateApp();
|
const navigateApp = useNavigateApp();
|
||||||
const { enterLayoutCustomizationMode } = useEnterLayoutCustomizationMode();
|
const { enterLayoutCustomizationMode } = useEnterLayoutCustomizationMode();
|
||||||
@@ -95,12 +96,30 @@ export const ObjectLayout = ({ objectMetadataItem }: ObjectLayoutProps) => {
|
|||||||
title={t`Record page`}
|
title={t`Record page`}
|
||||||
description={t`Customize the workspace record page`}
|
description={t`Customize the workspace record page`}
|
||||||
/>
|
/>
|
||||||
<SettingsCard
|
<SettingsDiscoveryHeroCard
|
||||||
title={t`Customize record page`}
|
lightSrc={recordPageLayoutCoverLight}
|
||||||
description={t`Customize how your record page looks.`}
|
darkSrc={recordPageLayoutCoverDark}
|
||||||
Icon={<IconAddressBook size={theme.icon.size.md} />}
|
coverHeight={153}
|
||||||
onClick={handleCustomizeRecordPage}
|
instanceIdPrefix={RECORD_PAGE_LAYOUT_HERO_INSTANCE_ID_PREFIX}
|
||||||
disabled={!hasLayoutsPermission || !isDefined(firstRecord)}
|
tabs={[]}
|
||||||
|
footer={
|
||||||
|
<SettingsDiscoveryHeroCardFooter
|
||||||
|
Icon={IconAddressBook}
|
||||||
|
title={t`Customize record page`}
|
||||||
|
description={t`Customize how your record page looks.`}
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
title={t`Customize`}
|
||||||
|
variant="primary"
|
||||||
|
accent="blue"
|
||||||
|
size="small"
|
||||||
|
Icon={IconPencil}
|
||||||
|
onClick={handleCustomizeRecordPage}
|
||||||
|
disabled={!hasLayoutsPermission || !isDefined(firstRecord)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
<Section>
|
<Section>
|
||||||
|
|||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
import { SettingsDiscoveryHeroCard } from '@/settings/components/SettingsDiscoveryHeroCard';
|
||||||
|
import { SettingsDiscoveryHeroCardFooter } from '@/settings/components/SettingsDiscoveryHeroCardFooter';
|
||||||
|
import recordPageLayoutCoverDark from '@/settings/data-model/object-details/assets/record-page-layout-cover-dark.png';
|
||||||
|
import recordPageLayoutCoverLight from '@/settings/data-model/object-details/assets/record-page-layout-cover-light.png';
|
||||||
|
import { styled } from '@linaria/react';
|
||||||
|
import { type Meta, type StoryObj } from '@storybook/react-vite';
|
||||||
|
import { expect, within } from 'storybook/test';
|
||||||
|
import { IconAddressBook, IconPencil } from 'twenty-ui/icon';
|
||||||
|
import { Button } from 'twenty-ui/input';
|
||||||
|
import { ThemeProvider, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||||
|
|
||||||
|
const StyledCanvas = styled.div`
|
||||||
|
background: ${themeCssVariables.background.primary};
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 32px;
|
||||||
|
width: 758px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const meta: Meta = {
|
||||||
|
title: 'Modules/Settings/Data Model/Object Layout Hero Card',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj;
|
||||||
|
|
||||||
|
const renderCard = (colorScheme: 'light' | 'dark') => (
|
||||||
|
<ThemeProvider colorScheme={colorScheme} applyToRoot={false}>
|
||||||
|
<StyledCanvas>
|
||||||
|
<SettingsDiscoveryHeroCard
|
||||||
|
lightSrc={recordPageLayoutCoverLight}
|
||||||
|
darkSrc={recordPageLayoutCoverDark}
|
||||||
|
coverHeight={153}
|
||||||
|
instanceIdPrefix={`object-layout-${colorScheme}`}
|
||||||
|
tabs={[]}
|
||||||
|
footer={
|
||||||
|
<SettingsDiscoveryHeroCardFooter
|
||||||
|
Icon={IconAddressBook}
|
||||||
|
title="Customize record page"
|
||||||
|
description="Customize how your record page looks."
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
title="Customize"
|
||||||
|
variant="primary"
|
||||||
|
accent="blue"
|
||||||
|
size="small"
|
||||||
|
Icon={IconPencil}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</StyledCanvas>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Light: Story = {
|
||||||
|
render: () => renderCard('light'),
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
expect(canvas.getByText('Customize record page')).toBeVisible();
|
||||||
|
expect(
|
||||||
|
canvas.getByText('Customize how your record page looks.'),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(canvas.getByRole('button', { name: 'Customize ...' })).toBeEnabled();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Dark: Story = {
|
||||||
|
render: () => renderCard('dark'),
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
expect(canvas.getByText('Customize record page')).toBeVisible();
|
||||||
|
expect(
|
||||||
|
canvas.getByText('Customize how your record page looks.'),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(canvas.getByRole('button', { name: 'Customize ...' })).toBeEnabled();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEnterLayoutCustomizationMode } from '@/layout-customization/hooks/useEnterLayoutCustomizationMode';
|
import { useEnterLayoutCustomizationMode } from '@/layout-customization/hooks/useEnterLayoutCustomizationMode';
|
||||||
import { SettingsDiscoveryHeroCard } from '@/settings/components/SettingsDiscoveryHeroCard';
|
import { SettingsDiscoveryHeroCard } from '@/settings/components/SettingsDiscoveryHeroCard';
|
||||||
import { SettingsOptionCardContentButton } from '@/settings/components/SettingsOptions/SettingsOptionCardContentButton';
|
import { SettingsDiscoveryHeroCardFooter } from '@/settings/components/SettingsDiscoveryHeroCardFooter';
|
||||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||||
import { SettingsLayoutItemsStats } from '@/settings/layout/components/SettingsLayoutItemsStats';
|
import { SettingsLayoutItemsStats } from '@/settings/layout/components/SettingsLayoutItemsStats';
|
||||||
import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout';
|
import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout';
|
||||||
@@ -88,11 +88,11 @@ export const SettingsLayout = () => {
|
|||||||
tabs={heroTabs}
|
tabs={heroTabs}
|
||||||
playButtonAriaLabel={t`Watch customization demo`}
|
playButtonAriaLabel={t`Watch customization demo`}
|
||||||
footer={
|
footer={
|
||||||
<SettingsOptionCardContentButton
|
<SettingsDiscoveryHeroCardFooter
|
||||||
Icon={IconLayoutDashboard}
|
Icon={IconLayoutDashboard}
|
||||||
title={t`Customize layout`}
|
title={t`Customize layout`}
|
||||||
description={t`Customize how your workspace looks.`}
|
description={t`Customize how your workspace looks.`}
|
||||||
Button={
|
action={
|
||||||
<Button
|
<Button
|
||||||
title={t`Customize`}
|
title={t`Customize`}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
|||||||
Reference in New Issue
Block a user