1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-11 00:08:30 +01:00

chore: group cards redesign (#9048)

https://linear.app/unleash/issue/2-3108/cards-design-groups

Redesigns the group cards.

Like instructed in the task, I took inspiration from the project and
integration cards, along with the Figma sketch.

Also includes a new `Truncator` generic helper component.

### Before


![image](https://github.com/user-attachments/assets/e47ebb3d-a089-4cbb-962c-53af9f1933f9)

### After


![image](https://github.com/user-attachments/assets/ffeb96b7-e6c4-4433-a847-2e267beb72e9)

Hovering over the "X projects" label reveals the projects the group
belongs to. You can navigate to any project by clicking its badge.


![image](https://github.com/user-attachments/assets/cf06c7f5-011e-4b89-8e40-ed42e5817625)

Truncated titles and descriptions show a tooltip with the full text on
hover.


![image](https://github.com/user-attachments/assets/6fc598e7-b08a-4bfa-8cb2-4153a81f2a48)


![image](https://github.com/user-attachments/assets/91ceba73-c43e-4070-9de0-2a182a3d9257)
This commit is contained in:
Nuno Góis 2025-01-02 15:08:15 +00:00 committed by GitHub
parent 54d6bd5b86
commit 67c1274a1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 288 additions and 150 deletions

View File

@ -1,4 +1,4 @@
import { styled, Tooltip } from '@mui/material';
import { Box, Card, styled } from '@mui/material';
import type { IGroup } from 'interfaces/group';
import { Link, useNavigate } from 'react-router-dom';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
@ -7,88 +7,119 @@ import { GroupCardActions } from './GroupCardActions/GroupCardActions';
import TopicOutlinedIcon from '@mui/icons-material/TopicOutlined';
import { RoleBadge } from 'component/common/RoleBadge/RoleBadge';
import { useScimSettings } from 'hooks/api/getters/useScimSettings/useScimSettings';
import { AvatarGroup } from 'component/common/AvatarGroup/AvatarGroup';
import {
AvatarComponent,
AvatarGroup,
} from 'component/common/AvatarGroup/AvatarGroup';
import GroupsIcon from '@mui/icons-material/GroupsOutlined';
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { Highlighter } from 'component/common/Highlighter/Highlighter';
import { Truncator } from 'component/common/Truncator/Truncator';
import { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
const StyledLink = styled(Link)(({ theme }) => ({
const StyledCardLink = styled(Link)(({ theme }) => ({
color: 'inherit',
textDecoration: 'none',
color: theme.palette.text.primary,
border: 'none',
padding: '0',
background: 'transparent',
fontFamily: theme.typography.fontFamily,
pointer: 'cursor',
}));
const StyledGroupCard = styled('aside')(({ theme }) => ({
padding: theme.spacing(2.5),
height: '100%',
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadiusLarge,
boxShadow: theme.boxShadows.card,
const StyledCard = styled(Card)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
[theme.breakpoints.up('md')]: {
padding: theme.spacing(4),
justifyContent: 'space-between',
height: '100%',
boxShadow: 'none',
border: `1px solid ${theme.palette.divider}`,
[theme.breakpoints.down('sm')]: {
justifyContent: 'center',
},
transition: 'background-color 0.2s ease-in-out',
backgroundColor: theme.palette.background.default,
'&:hover': {
transition: 'background-color 0.2s ease-in-out',
backgroundColor: theme.palette.neutral.light,
},
borderRadius: theme.shape.borderRadiusMedium,
}));
const StyledRow = styled('div')(() => ({
const StyledCardBody = styled(Box)(({ theme }) => ({
padding: theme.spacing(2),
display: 'flex',
flexFlow: 'column',
height: '100%',
position: 'relative',
}));
const StyledCardBodyHeader = styled('div')(({ theme }) => ({
display: 'flex',
gap: theme.spacing(1),
width: '100%',
alignItems: 'center',
justifyContent: 'space-between',
}));
const StyledTitleRow = styled(StyledRow)(() => ({
alignItems: 'flex-start',
const StyledCardIconContainer = styled(Box)(({ theme }) => ({
display: 'grid',
placeItems: 'center',
padding: theme.spacing(0.5),
alignSelf: 'baseline',
backgroundColor: theme.palette.secondary.light,
color: theme.palette.primary.main,
borderRadius: theme.shape.borderRadiusMedium,
'& > svg': {
height: theme.spacing(2),
width: theme.spacing(2),
},
}));
const StyledBottomRow = styled(StyledRow)(({ theme }) => ({
marginTop: 'auto',
alignItems: 'flex-end',
const StyledCardTitle = styled('h3')(({ theme }) => ({
margin: 0,
marginRight: 'auto',
fontWeight: theme.typography.fontWeightRegular,
fontSize: theme.typography.body1.fontSize,
lineHeight: '1.2',
}));
const StyledCardDescription = styled('p')(({ theme }) => ({
color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallBody,
marginTop: theme.spacing(2),
}));
const StyledCardFooter = styled(Box)(({ theme }) => ({
padding: theme.spacing(0, 2),
display: 'flex',
background: theme.palette.envAccordion.expanded,
boxShadow: theme.boxShadows.accordionFooter,
alignItems: 'center',
justifyContent: 'space-between',
borderTop: `1px solid ${theme.palette.divider}`,
minHeight: theme.spacing(6.25),
}));
const StyledCardFooterSpan = styled('span')(({ theme }) => ({
fontSize: theme.fontSizes.smallerBody,
color: theme.palette.text.secondary,
textWrap: 'nowrap',
}));
const StyledAvatarComponent = styled(AvatarComponent)(({ theme }) => ({
height: theme.spacing(2.5),
width: theme.spacing(2.5),
marginLeft: theme.spacing(-0.75),
}));
const StyledProjectsTooltip = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
}));
const StyledHeaderTitle = styled('h2')(({ theme }) => ({
fontSize: theme.fontSizes.mainHeader,
fontWeight: theme.fontWeight.medium,
}));
const StyledHeaderActions = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallBody,
}));
const StyledDescription = styled('p')(({ theme }) => ({
color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallBody,
marginTop: theme.spacing(1),
marginBottom: theme.spacing(4),
}));
const StyledCounterDescription = styled('span')(({ theme }) => ({
color: theme.palette.text.secondary,
marginLeft: theme.spacing(1),
}));
const ProjectBadgeContainer = styled('div')(({ theme }) => ({
maxWidth: '50%',
display: 'flex',
justifyContent: 'flex-end',
gap: theme.spacing(0.5),
flexWrap: 'wrap',
}));
const InfoBadgeDescription = styled('span')(({ theme }) => ({
display: 'flex',
color: theme.palette.text.secondary,
alignItems: 'center',
gap: theme.spacing(1),
fontSize: theme.fontSizes.smallBody,
}));
const ProjectNameBadge = styled(Badge)({
wordBreak: 'break-word',
const StyledProjectBadge = styled(Badge)({
cursor: 'pointer',
});
interface IGroupCardProps {
@ -104,6 +135,8 @@ export const GroupCard = ({
}: IGroupCardProps) => {
const navigate = useNavigate();
const { searchQuery } = useSearchHighlightContext();
const {
settings: { enabled: scimEnabled },
} = useScimSettings();
@ -111,84 +144,104 @@ export const GroupCard = ({
return (
<>
<StyledLink key={group.id} to={`/admin/groups/${group.id}`}>
<StyledGroupCard>
<StyledTitleRow>
<StyledHeaderTitle>{group.name}</StyledHeaderTitle>
<StyledHeaderActions>
<StyledCardLink to={`/admin/groups/${group.id}`}>
<StyledCard>
<StyledCardBody>
<StyledCardBodyHeader>
<StyledCardIconContainer>
<GroupsIcon />
</StyledCardIconContainer>
<Truncator
title={group.name}
arrow
component={StyledCardTitle}
>
<Highlighter search={searchQuery}>
{group.name}
</Highlighter>
</Truncator>
<ConditionallyRender
condition={Boolean(group.rootRole)}
show={
<RoleBadge
roleId={group.rootRole!}
hideIcon
/>
}
/>
<GroupCardActions
groupId={group.id}
onEditUsers={() => onEditUsers(group)}
onRemove={() => onRemoveGroup(group)}
isScimGroup={isScimGroup}
/>
</StyledHeaderActions>
</StyledTitleRow>
<ConditionallyRender
condition={Boolean(group.rootRole)}
show={
<InfoBadgeDescription>
<p>Root role:</p>
<RoleBadge roleId={group.rootRole!} />
</InfoBadgeDescription>
}
/>
<StyledDescription>{group.description}</StyledDescription>
<StyledBottomRow>
</StyledCardBodyHeader>
<ConditionallyRender
condition={group.users?.length > 0}
show={<AvatarGroup users={group.users} />}
elseShow={
<StyledCounterDescription>
This group has no users.
</StyledCounterDescription>
condition={Boolean(group.description)}
show={
<Truncator
lines={2}
title={group.description}
arrow
component={StyledCardDescription}
>
<Highlighter search={searchQuery}>
{group.description}
</Highlighter>
</Truncator>
}
/>
<ProjectBadgeContainer>
<ConditionallyRender
condition={group.projects.length > 0}
show={group.projects.map((project) => (
<Tooltip
key={project}
title='View project'
arrow
placement='bottom-end'
describeChild
>
<ProjectNameBadge
onClick={(e) => {
e.preventDefault();
navigate(
`/projects/${project}/settings/access`,
);
}}
color='secondary'
icon={<TopicOutlinedIcon />}
>
{project}
</ProjectNameBadge>
</Tooltip>
))}
elseShow={
<ConditionallyRender
condition={!group.rootRole}
show={
<Tooltip
title='This group is not used in any project'
arrow
describeChild
>
<Badge>Not used</Badge>
</Tooltip>
}
/>
}
/>
</ProjectBadgeContainer>
</StyledBottomRow>
</StyledGroupCard>
</StyledLink>
</StyledCardBody>
<StyledCardFooter>
<ConditionallyRender
condition={group.users.length > 0}
show={
<AvatarGroup
users={group.users}
AvatarComponent={StyledAvatarComponent}
/>
}
elseShow={
<StyledCardFooterSpan>
This group has no users
</StyledCardFooterSpan>
}
/>
<ConditionallyRender
condition={group.projects.length > 0}
show={
<TooltipLink
component='span'
tooltip={
<StyledProjectsTooltip>
{group.projects.map((project) => (
<StyledProjectBadge
key={project}
onClick={(e) => {
e.preventDefault();
navigate(
`/projects/${project}/settings/access`,
);
}}
color='secondary'
icon={<TopicOutlinedIcon />}
>
{project}
</StyledProjectBadge>
))}
</StyledProjectsTooltip>
}
>
<StyledCardFooterSpan>
{group.projects.length} project
{group.projects.length !== 1 && 's'}
</StyledCardFooterSpan>
</TooltipLink>
}
/>
</StyledCardFooter>
</StyledCard>
</StyledCardLink>
</>
);
};

View File

@ -18,9 +18,11 @@ import { Link } from 'react-router-dom';
import { scimGroupTooltip } from 'component/admin/groups/group-constants';
const StyledActions = styled('div')(({ theme }) => ({
margin: theme.spacing(-1),
marginLeft: theme.spacing(-0.5),
display: 'flex',
justifyContent: 'center',
transform: 'translate3d(8px, -6px, 0)',
alignItems: 'center',
}));
const StyledPopover = styled(Popover)(({ theme }) => ({
@ -51,7 +53,7 @@ export const GroupCardActions: FC<IGroupCardActions> = ({
setAnchorEl(null);
};
const id = `feature-${groupId}-actions`;
const id = `group-${groupId}-actions`;
const menuId = `${id}-menu`;
return (
@ -74,6 +76,7 @@ export const GroupCardActions: FC<IGroupCardActions> = ({
aria-expanded={open ? 'true' : undefined}
onClick={handleClick}
type='button'
size='small'
>
<MoreVert />
</IconButton>

View File

@ -6,7 +6,7 @@ import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Search } from 'component/common/Search/Search';
import { Grid, useMediaQuery } from '@mui/material';
import { styled, useMediaQuery } from '@mui/material';
import theme from 'themes/theme';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { TablePlaceholder } from 'component/common/Table';
@ -19,6 +19,12 @@ import { NAVIGATE_TO_CREATE_GROUP } from 'utils/testIds';
import { EditGroupUsers } from '../Group/EditGroupUsers/EditGroupUsers';
import { RemoveGroup } from '../RemoveGroup/RemoveGroup';
const StyledGridContainer = styled('div')(({ theme }) => ({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: theme.spacing(2),
}));
type PageQueryType = Partial<Record<'search', string>>;
const groupsSearch = (group: IGroup, searchValue: string) => {
@ -131,17 +137,16 @@ export const GroupsList: VFC = () => {
}
>
<SearchHighlightProvider value={searchValue}>
<Grid container spacing={2}>
<StyledGridContainer>
{data.map((group) => (
<Grid key={group.id} item xs={12} md={6}>
<GroupCard
group={group}
onEditUsers={onEditUsers}
onRemoveGroup={onRemoveGroup}
/>
</Grid>
<GroupCard
key={group.id}
group={group}
onEditUsers={onEditUsers}
onRemoveGroup={onRemoveGroup}
/>
))}
</Grid>
</StyledGridContainer>
</SearchHighlightProvider>
<ConditionallyRender
condition={!loading && data.length === 0}

View File

@ -85,7 +85,7 @@ const AvatarGroupInner = ({
return 0;
})
.slice(0, avatarLimit),
[users],
[users, avatarLimit],
);
const overflow = users.length - avatarLimit;

View File

@ -6,16 +6,19 @@ import { RoleDescription } from 'component/common/RoleDescription/RoleDescriptio
interface IRoleBadgeProps {
roleId: number;
hideIcon?: boolean;
children?: string;
}
export const RoleBadge = ({ roleId, children }: IRoleBadgeProps) => {
export const RoleBadge = ({ roleId, hideIcon, children }: IRoleBadgeProps) => {
const { role } = useRole(roleId.toString());
const icon = hideIcon ? undefined : <UserIcon />;
if (!role) {
if (children)
return (
<Badge color='success' icon={<UserIcon />}>
<Badge color='success' icon={icon}>
{children}
</Badge>
);
@ -24,11 +27,7 @@ export const RoleBadge = ({ roleId, children }: IRoleBadgeProps) => {
return (
<HtmlTooltip title={<RoleDescription roleId={roleId} tooltip />} arrow>
<Badge
color='success'
icon={<UserIcon />}
sx={{ cursor: 'pointer' }}
>
<Badge color='success' icon={icon} sx={{ cursor: 'pointer' }}>
{role.name}
</Badge>
</HtmlTooltip>

View File

@ -8,6 +8,9 @@ interface IStringTruncatorProps {
maxLength: number;
}
/**
* @Deprecated in favor of Truncator
*/
const StringTruncator = ({
text,
maxWidth,

View File

@ -0,0 +1,75 @@
import { useState, useEffect, useRef } from 'react';
import {
Box,
type BoxProps,
styled,
Tooltip,
type TooltipProps,
} from '@mui/material';
const StyledTruncatorContainer = styled(Box, {
shouldForwardProp: (prop) => prop !== 'lines',
})<{ lines: number }>(({ lines }) => ({
lineClamp: `${lines}`,
WebkitLineClamp: lines,
display: '-webkit-box',
boxOrient: 'vertical',
textOverflow: 'ellipsis',
overflow: 'hidden',
alignItems: 'flex-start',
WebkitBoxOrient: 'vertical',
wordBreak: 'break-word',
}));
type OverridableTooltipProps = Omit<TooltipProps, 'children'>;
interface ITruncatorProps extends BoxProps {
lines?: number;
title?: string;
arrow?: boolean;
tooltipProps?: OverridableTooltipProps;
children: React.ReactNode;
}
export const Truncator = ({
lines = 1,
title,
arrow,
tooltipProps,
children,
...props
}: ITruncatorProps) => {
const [isTruncated, setIsTruncated] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const checkTruncation = () => {
if (ref.current) {
setIsTruncated(ref.current.scrollHeight > ref.current.offsetHeight);
}
};
useEffect(() => {
const resizeObserver = new ResizeObserver(checkTruncation);
if (ref.current) {
resizeObserver.observe(ref.current);
}
return () => resizeObserver.disconnect();
}, [title, children]);
const overridableTooltipProps: OverridableTooltipProps = {
title,
arrow,
...tooltipProps,
};
const { title: tooltipTitle, ...otherTooltipProps } =
overridableTooltipProps;
return (
<Tooltip title={isTruncated ? tooltipTitle : ''} {...otherTooltipProps}>
<StyledTruncatorContainer ref={ref} lines={lines} {...props}>
{children}
</StyledTruncatorContainer>
</Tooltip>
);
};