1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-10-28 19:06:12 +01:00

feat: new project card (#7992)

This commit is contained in:
Tymoteusz Czech 2024-08-28 18:26:41 +02:00 committed by GitHub
parent 3188f991f4
commit 8923e28d83
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 415 additions and 111 deletions

View File

@ -2,15 +2,10 @@ import type { FC } from 'react';
import useToast from 'hooks/useToast';
import { useFavoriteProjectsApi } from 'hooks/api/actions/useFavoriteProjectsApi/useFavoriteProjectsApi';
import useProjects from 'hooks/api/getters/useProjects/useProjects';
import { styled } from '@mui/material';
import { FavoriteIconButton } from 'component/common/FavoriteIconButton/FavoriteIconButton';
type FavoriteActionProps = { id: string; isFavorite?: boolean };
const StyledFavoriteIconButton = styled(FavoriteIconButton)(({ theme }) => ({
margin: theme.spacing(1, 2, 0, 0),
}));
export const FavoriteAction: FC<FavoriteActionProps> = ({ id, isFavorite }) => {
const { setToastApiError } = useToast();
const { favorite, unfavorite } = useFavoriteProjectsApi();
@ -31,7 +26,7 @@ export const FavoriteAction: FC<FavoriteActionProps> = ({ id, isFavorite }) => {
};
return (
<StyledFavoriteIconButton
<FavoriteIconButton
onClick={onFavorite}
isFavorite={Boolean(isFavorite)}
size='medium'

View File

@ -12,21 +12,10 @@ import {
} from './ProjectCard.styles';
import { ProjectCardFooter } from './ProjectCardFooter/ProjectCardFooter';
import { ProjectModeBadge } from './ProjectModeBadge/ProjectModeBadge';
import type { ProjectSchemaOwners } from 'openapi';
import { ProjectIcon } from 'component/common/ProjectIcon/ProjectIcon';
import { FavoriteAction } from './ProjectCardFooter/FavoriteAction/FavoriteAction';
interface IProjectCardProps {
name: string;
featureCount: number;
health: number;
memberCount?: number;
id: string;
onHover: () => void;
favorite?: boolean;
mode: string;
owners?: ProjectSchemaOwners;
}
import { FavoriteAction } from './FavoriteAction/FavoriteAction';
import type { IProjectCard } from 'interfaces/project';
import { Box } from '@mui/material';
export const ProjectCard = ({
name,
@ -38,7 +27,7 @@ export const ProjectCard = ({
mode,
favorite = false,
owners,
}: IProjectCardProps) => (
}: IProjectCard) => (
<StyledProjectCard onMouseEnter={onHover}>
<StyledProjectCardBody>
<StyledDivHeader>
@ -79,7 +68,9 @@ export const ProjectCard = ({
</StyledDivInfo>
</StyledProjectCardBody>
<ProjectCardFooter id={id} owners={owners}>
<FavoriteAction id={id} isFavorite={favorite} />
<Box sx={(theme) => ({ margin: theme.spacing(1, 2, 0, 0) })}>
<FavoriteAction id={id} isFavorite={favorite} />
</Box>
</ProjectCardFooter>
</StyledProjectCard>
);

View File

@ -34,7 +34,6 @@ export type ProjectArchiveCardProps = {
id: string;
name: string;
archivedAt?: string;
archivedFeaturesCount?: number;
onRevive: () => void;
onDelete: () => void;
mode?: string;
@ -45,7 +44,6 @@ export const ProjectArchiveCard: FC<ProjectArchiveCardProps> = ({
id,
name,
archivedAt,
archivedFeaturesCount,
onRevive,
onDelete,
mode,

View File

@ -41,20 +41,22 @@ export const StyledDivHeader = styled('div')(({ theme }) => ({
gap: theme.spacing(1),
}));
export const StyledCardTitle = styled('h3')(({ theme }) => ({
fontWeight: theme.typography.fontWeightRegular,
fontSize: theme.typography.body1.fontSize,
lineClamp: '2',
WebkitLineClamp: 2,
lineHeight: '1.2',
display: '-webkit-box',
boxOrient: 'vertical',
textOverflow: 'ellipsis',
overflow: 'hidden',
alignItems: 'flex-start',
WebkitBoxOrient: 'vertical',
wordBreak: 'break-word',
}));
export const StyledCardTitle = styled('h3')<{ lines?: number }>(
({ theme, lines = 2 }) => ({
fontWeight: theme.typography.fontWeightRegular,
fontSize: theme.typography.body1.fontSize,
lineClamp: `${lines}`,
WebkitLineClamp: lines,
lineHeight: '1.2',
display: '-webkit-box',
boxOrient: 'vertical',
textOverflow: 'ellipsis',
overflow: 'hidden',
alignItems: 'flex-start',
WebkitBoxOrient: 'vertical',
wordBreak: 'break-word',
}),
);
export const StyledBox = styled(Box)(() => ({
...flexRow,

View File

@ -1,32 +1,37 @@
import { DEFAULT_PROJECT_ID } from 'hooks/api/getters/useDefaultProject/useDefaultProjectId';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import {
StyledProjectCard,
StyledDivHeader,
StyledBox,
StyledCardTitle,
StyledDivInfo,
StyledParagraphInfo,
StyledProjectCardBody,
StyledIconBox,
} from './ProjectCard.styles';
import { ProjectCardFooter } from './ProjectCardFooter/ProjectCardFooter';
import { ProjectModeBadge } from './ProjectModeBadge/ProjectModeBadge';
import type { ProjectSchemaOwners } from 'openapi';
import { ProjectIcon } from 'component/common/ProjectIcon/ProjectIcon';
import { FavoriteAction } from './ProjectCardFooter/FavoriteAction/FavoriteAction';
import { FavoriteAction } from './FavoriteAction/FavoriteAction';
import { Box, styled } from '@mui/material';
import { flexColumn } from 'themes/themeStyles';
import { TimeAgo } from 'component/common/TimeAgo/TimeAgo';
import { ProjectLastSeen } from './ProjectLastSeen/ProjectLastSeen';
import type { IProjectCard } from 'interfaces/project';
interface IProjectCardProps {
name: string;
featureCount: number;
health: number;
memberCount?: number;
id: string;
onHover: () => void;
favorite?: boolean;
mode: string;
owners?: ProjectSchemaOwners;
}
const StyledUpdated = styled('span')(({ theme }) => ({
color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallerBody,
}));
const StyledCount = styled('strong')(({ theme }) => ({
fontWeight: theme.typography.fontWeightMedium,
}));
const StyledInfo = styled('div')(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
marginTop: theme.spacing(1),
fontSize: theme.fontSizes.smallerBody,
alignItems: 'flex-end',
}));
export const ProjectCard = ({
name,
@ -38,48 +43,50 @@ export const ProjectCard = ({
mode,
favorite = false,
owners,
}: IProjectCardProps) => (
lastUpdatedAt,
lastReportedFlagUsage,
}: IProjectCard) => (
<StyledProjectCard onMouseEnter={onHover}>
<StyledProjectCardBody>
<StyledDivHeader>
<StyledIconBox>
<ProjectIcon />
</StyledIconBox>
<StyledBox data-loading>
<StyledCardTitle>{name}</StyledCardTitle>
</StyledBox>
<Box
data-loading
sx={(theme) => ({
...flexColumn,
margin: theme.spacing(1, 'auto', 1, 0),
})}
>
<StyledCardTitle lines={1} sx={{ margin: 0 }}>
{name}
</StyledCardTitle>
<ConditionallyRender
condition={Boolean(lastUpdatedAt)}
show={
<StyledUpdated>
Updated <TimeAgo date={lastUpdatedAt} />
</StyledUpdated>
}
/>
</Box>
<ProjectModeBadge mode={mode} />
<FavoriteAction id={id} isFavorite={favorite} />
</StyledDivHeader>
<StyledDivInfo>
<StyledInfo>
<div>
<StyledParagraphInfo data-loading>
{featureCount}
</StyledParagraphInfo>
<p data-loading>{featureCount === 1 ? 'flag' : 'flags'}</p>
<div>
<StyledCount>{featureCount}</StyledCount> flag
{featureCount === 1 ? '' : 's'}
</div>
<div>
<StyledCount>{health}%</StyledCount> health
</div>
</div>
<ConditionallyRender
condition={id !== DEFAULT_PROJECT_ID}
show={
<div>
<StyledParagraphInfo data-loading>
{memberCount}
</StyledParagraphInfo>
<p data-loading>
{memberCount === 1 ? 'member' : 'members'}
</p>
</div>
}
/>
<div>
<StyledParagraphInfo data-loading>
{health}%
</StyledParagraphInfo>
<p data-loading>healthy</p>
</div>
</StyledDivInfo>
<ProjectLastSeen date={lastReportedFlagUsage} />
</StyledInfo>
</StyledProjectCardBody>
<ProjectCardFooter id={id} owners={owners}>
<FavoriteAction id={id} isFavorite={favorite} />
</ProjectCardFooter>
<ProjectCardFooter id={id} owners={owners} memberCount={memberCount} />
</StyledProjectCard>
);

View File

@ -3,15 +3,21 @@ import type { FC } from 'react';
import { Box, styled } from '@mui/material';
import {
type IProjectOwnersProps,
ProjectOwners,
} from '../ProjectOwners/ProjectOwners';
ProjectOwners as LegacyProjectOwners,
} from '../LegacyProjectOwners/LegacyProjectOwners';
import { ProjectOwners } from './ProjectOwners/ProjectOwners';
import { useUiFlag } from 'hooks/useUiFlag';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { ProjectMembers } from './ProjectMembers/ProjectMembers';
import { DEFAULT_PROJECT_ID } from 'hooks/api/getters/useDefaultProject/useDefaultProjectId';
interface IProjectCardFooterProps {
id: string;
id?: string;
isFavorite?: boolean;
children?: React.ReactNode;
disabled?: boolean;
owners: IProjectOwnersProps['owners'];
memberCount?: number;
}
const StyledFooter = styled(Box)<{ disabled: boolean }>(
@ -28,14 +34,29 @@ const StyledFooter = styled(Box)<{ disabled: boolean }>(
);
export const ProjectCardFooter: FC<IProjectCardFooterProps> = ({
id,
children,
owners,
disabled = false,
memberCount,
}) => {
const projectListImprovementsEnabled = useUiFlag('projectListImprovements');
return (
<StyledFooter disabled={disabled}>
<ProjectOwners owners={owners} />
{children}
<ConditionallyRender
condition={Boolean(projectListImprovementsEnabled)}
show={<ProjectOwners owners={owners} />}
elseShow={<LegacyProjectOwners owners={owners} />}
/>
<ConditionallyRender
condition={
Boolean(projectListImprovementsEnabled) &&
id !== DEFAULT_PROJECT_ID
}
show={<ProjectMembers count={memberCount} members={[]} />}
elseShow={children}
/>
</StyledFooter>
);
};

View File

@ -0,0 +1,55 @@
import type { FC } from 'react';
import { styled } from '@mui/material';
import { AvatarGroup } from 'component/common/AvatarGroup/AvatarGroup';
import { UserAvatar } from 'component/common/UserAvatar/UserAvatar';
import { flexColumn } from 'themes/themeStyles';
type ProjectMembersProps = {
count?: number;
members: Array<{
imageUrl?: string;
email?: string;
name: string;
}>;
};
const StyledContainer = styled('div')(({ theme }) => ({
...flexColumn,
alignItems: 'flex-end',
padding: theme.spacing(0, 2, 0, 1),
minWidth: 95,
}));
const StyledDescription = styled('span')(({ theme }) => ({
fontSize: theme.fontSizes.smallerBody,
color: theme.palette.text.secondary,
textWrap: 'nowrap',
}));
const StyledAvatar = styled(UserAvatar)(({ theme }) => ({
width: theme.spacing(2),
height: theme.spacing(2),
}));
const AvatarComponent = ({ ...props }) => (
<StyledAvatar {...props} disableTooltip />
);
export const ProjectMembers: FC<ProjectMembersProps> = ({
count = 0,
members,
}) => {
return (
<StyledContainer>
<StyledDescription data-loading>
{count} member
{count === 1 ? '' : 's'}
</StyledDescription>
<AvatarGroup
users={members}
avatarLimit={4}
AvatarComponent={AvatarComponent}
/>
</StyledContainer>
);
};

View File

@ -0,0 +1,113 @@
import type { FC } from 'react';
import { styled } from '@mui/material';
import type { ProjectSchema, ProjectSchemaOwners } from 'openapi';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import {
AvatarGroup,
AvatarComponent,
} from 'component/common/AvatarGroup/AvatarGroup';
export interface IProjectOwnersProps {
owners?: ProjectSchema['owners'];
}
const useOwnersMap = () => {
const { uiConfig } = useUiConfig();
return (
owner: ProjectSchemaOwners[0],
): {
name: string;
imageUrl?: string;
email?: string;
} => {
if (owner.ownerType === 'user') {
return {
name: owner.name,
imageUrl: owner.imageUrl || undefined,
email: owner.email || undefined,
};
}
if (owner.ownerType === 'group') {
return {
name: owner.name,
};
}
return {
name: 'System',
imageUrl: `${uiConfig.unleashUrl}/logo-unleash.png`,
};
};
};
const StyledUserName = styled('span')(({ theme }) => ({
fontSize: theme.typography.body2.fontSize,
alignSelf: 'end',
lineHeight: 1,
lineClamp: `1`,
WebkitLineClamp: 1,
display: '-webkit-box',
boxOrient: 'vertical',
textOverflow: 'ellipsis',
overflow: 'hidden',
alignItems: 'flex-start',
WebkitBoxOrient: 'vertical',
wordBreak: 'break-word',
maxWidth: '100%',
}));
const StyledContainer = styled('div')(() => ({
display: 'flex',
flexDirection: 'column',
}));
const StyledOwnerName = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(0.25),
margin: theme.spacing(0, 0, 0, 1),
}));
const StyledHeader = styled('span')(({ theme }) => ({
lineHeight: 1,
fontSize: theme.fontSizes.smallerBody,
color: theme.palette.text.secondary,
fontWeight: theme.typography.fontWeightRegular,
}));
const StyledWrapper = styled('div')(({ theme }) => ({
padding: theme.spacing(1.5, 0, 1.5, 2),
display: 'flex',
alignItems: 'center',
}));
const StyledAvatarComponent = styled(AvatarComponent)(({ theme }) => ({
cursor: 'default',
}));
export const ProjectOwners: FC<IProjectOwnersProps> = ({ owners = [] }) => {
const ownersMap = useOwnersMap();
const users = owners.map(ownersMap);
return (
<StyledWrapper data-testid='test'>
<StyledContainer>
<AvatarGroup
users={users}
avatarLimit={4}
AvatarComponent={StyledAvatarComponent}
/>
</StyledContainer>
<ConditionallyRender
condition={owners.length === 1}
show={
<StyledOwnerName>
<StyledHeader>Owner</StyledHeader>
<StyledUserName>{users[0]?.name}</StyledUserName>
</StyledOwnerName>
}
/>
</StyledWrapper>
);
};

View File

@ -0,0 +1,80 @@
import type { FC } from 'react';
import { useLastSeenColors } from 'component/feature/FeatureView/FeatureEnvironmentSeen/useLastSeenColors';
import { Box, styled, Typography } from '@mui/material';
import { ReactComponent as UsageLine } from 'assets/icons/usage-line.svg';
import { ReactComponent as UsageRate } from 'assets/icons/usage-rate.svg';
import { StyledIconWrapper } from 'component/feature/FeatureView/FeatureEnvironmentSeen/FeatureEnvironmentSeen';
import { flexRow } from 'themes/themeStyles';
import { TimeAgo } from 'component/common/TimeAgo/TimeAgo';
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
type ProjectLastSeenProps = {
date?: Date | number | string | null;
};
const StyledContainer = styled(Box)(({ theme }) => ({
...flexRow,
justifyContent: 'flex-start',
textWrap: 'nowrap',
width: '50%',
gap: theme.spacing(1),
cursor: 'default',
}));
const StyledIcon = styled(StyledIconWrapper)<{ background: string }>(
({ background }) => ({
background,
margin: 0,
}),
);
const Title = () => (
<>
<Typography
component='span'
sx={(theme) => ({ fontSize: theme.fontSizes.smallBody })}
>
Last usage reported
</Typography>
<Typography
sx={(theme) => ({
color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallerBody,
})}
>
Across all flags in your project this is the last time usage metrics
where reported from connected applications.
</Typography>
</>
);
export const ProjectLastSeen: FC<ProjectLastSeenProps> = ({ date }) => {
const getColor = useLastSeenColors();
const { text, background } = getColor(date);
if (!date) {
return (
<HtmlTooltip title={<Title />} arrow>
<StyledContainer
sx={(theme) => ({ color: theme.palette.text.secondary })}
>
<StyledIcon background={background}>
<UsageLine stroke={text} />
</StyledIcon>{' '}
<div>No activity</div>
</StyledContainer>
</HtmlTooltip>
);
}
return (
<HtmlTooltip title={<Title />} arrow>
<StyledContainer>
<StyledIcon background={background}>
<UsageRate stroke={text} />
</StyledIcon>{' '}
<TimeAgo date={date} refresh={false} />
</StyledContainer>
</HtmlTooltip>
);
};

View File

@ -1,15 +1,39 @@
import type { FC } from 'react';
import LockIcon from '@mui/icons-material/Lock';
import ProtectedProjectIcon from '@mui/icons-material/LockOutlined';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import PrivateProjectIcon from '@mui/icons-material/VisibilityOffOutlined';
import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip';
import { Badge } from 'component/common/Badge/Badge';
import { useUiFlag } from 'hooks/useUiFlag';
import { styled } from '@mui/material';
interface IProjectModeBadgeProps {
mode?: 'private' | 'protected' | 'public' | string;
}
const StyledIcon = styled('div')(({ theme }) => ({
color: theme.palette.primary.main,
fontSize: theme.spacing(2.25),
paddingTop: theme.spacing(0.75),
}));
export const ProjectModeBadge: FC<IProjectModeBadgeProps> = ({ mode }) => {
const projectListImprovementsEnabled = useUiFlag('projectListImprovements');
if (mode === 'private') {
if (projectListImprovementsEnabled) {
return (
<HtmlTooltip
title="This project's collaboration mode is set to private. The project and associated feature flags can only be seen by members of the project."
arrow
>
<StyledIcon>
<PrivateProjectIcon fontSize='inherit' />
</StyledIcon>
</HtmlTooltip>
);
}
return (
<HtmlTooltip
title="This project's collaboration mode is set to private. The project and associated feature flags can only be seen by members of the project."
@ -21,6 +45,18 @@ export const ProjectModeBadge: FC<IProjectModeBadgeProps> = ({ mode }) => {
}
if (mode === 'protected') {
if (projectListImprovementsEnabled) {
return (
<HtmlTooltip
title="This project's collaboration mode is set to protected. Only admins and project members can submit change requests."
arrow
>
<StyledIcon>
<ProtectedProjectIcon fontSize='inherit' />
</StyledIcon>
</HtmlTooltip>
);
}
return (
<HtmlTooltip
title="This project's collaboration mode is set to protected. Only admins and project members can submit change requests."

View File

@ -2,11 +2,13 @@ import type { ComponentType } from 'react';
import { Link } from 'react-router-dom';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { ProjectCard as LegacyProjectCard } from '../ProjectCard/LegacyProjectCard';
import { ProjectCard as NewProjectCard } from '../ProjectCard/ProjectCard';
import type { IProjectCard } from 'interfaces/project';
import loadingData from './loadingData';
import { TablePlaceholder } from 'component/common/Table';
import { styled, Typography } from '@mui/material';
import { useUiFlag } from 'hooks/useUiFlag';
const StyledGridContainer = styled('div')(({ theme }) => ({
display: 'grid',
@ -24,25 +26,30 @@ const StyledCardLink = styled(Link)(({ theme }) => ({
pointer: 'cursor',
}));
type ProjectGroupProps<T extends { id: string } = IProjectCard> = {
type ProjectGroupProps = {
sectionTitle?: string;
projects: T[];
projects: IProjectCard[];
loading: boolean;
searchValue: string;
placeholder?: string;
ProjectCardComponent?: ComponentType<T & any>;
ProjectCardComponent?: ComponentType<IProjectCard & any>;
link?: boolean;
};
export const ProjectGroup = <T extends { id: string }>({
export const ProjectGroup = ({
sectionTitle,
projects,
loading,
searchValue,
placeholder = 'No projects available.',
ProjectCardComponent = LegacyProjectCard,
ProjectCardComponent,
link = true,
}: ProjectGroupProps<T>) => {
}: ProjectGroupProps) => {
const projectListImprovementsEnabled = useUiFlag('projectListImprovements');
const ProjectCard =
ProjectCardComponent ??
(projectListImprovementsEnabled ? NewProjectCard : LegacyProjectCard);
return (
<article>
<ConditionallyRender
@ -82,9 +89,9 @@ export const ProjectGroup = <T extends { id: string }>({
<>
{loadingData.map(
(project: IProjectCard) => (
<LegacyProjectCard
<ProjectCard
data-loading
onHover={() => {}}
createdAt={project.createdAt}
key={project.id}
name={project.name}
id={project.id}
@ -99,21 +106,17 @@ export const ProjectGroup = <T extends { id: string }>({
)}
elseShow={() => (
<>
{projects.map((project: T) =>
{projects.map((project) =>
link ? (
<StyledCardLink
key={project.id}
to={`/projects/${project.id}`}
>
<ProjectCardComponent
onHover={() => {}}
{...project}
/>
<ProjectCard {...project} />
</StyledCardLink>
) : (
<ProjectCardComponent
<ProjectCard
key={project.id}
onHover={() => {}}
{...project}
/>
),

View File

@ -7,13 +7,16 @@ export interface IProjectCard {
name: string;
id: string;
createdAt: string;
health: number;
description: string;
featureCount: number;
mode: string;
health?: number;
description?: string;
featureCount?: number;
mode?: string;
memberCount?: number;
onHover?: () => void;
favorite?: boolean;
owners?: ProjectSchema['owners'];
lastUpdatedAt?: Date;
lastReportedFlagUsage?: Date;
}
export type FeatureNamingType = {