From 8923e28d83a5617e41cf012dea72be3ae343aa2d Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Wed, 28 Aug 2024 18:26:41 +0200 Subject: [PATCH] feat: new project card (#7992) --- .../FavoriteAction/FavoriteAction.tsx | 7 +- .../project/ProjectCard/LegacyProjectCard.tsx | 23 ++-- .../LegacyProjectOwners.tsx} | 0 .../ProjectCard/ProjectArchiveCard.tsx | 2 - .../project/ProjectCard/ProjectCard.styles.ts | 30 ++--- .../project/ProjectCard/ProjectCard.tsx | 105 ++++++++-------- .../ProjectCardFooter/ProjectCardFooter.tsx | 31 ++++- .../ProjectMembers/ProjectMembers.tsx | 55 +++++++++ .../ProjectOwners/ProjectOwners.tsx | 113 ++++++++++++++++++ .../ProjectLastSeen/ProjectLastSeen.tsx | 80 +++++++++++++ .../ProjectModeBadge/ProjectModeBadge.tsx | 36 ++++++ .../project/ProjectList/ProjectGroup.tsx | 33 ++--- frontend/src/interfaces/project.ts | 11 +- 13 files changed, 415 insertions(+), 111 deletions(-) rename frontend/src/component/project/ProjectCard/{ProjectCardFooter => }/FavoriteAction/FavoriteAction.tsx (85%) rename frontend/src/component/project/ProjectCard/{ProjectOwners/ProjectOwners.tsx => LegacyProjectOwners/LegacyProjectOwners.tsx} (100%) create mode 100644 frontend/src/component/project/ProjectCard/ProjectCardFooter/ProjectMembers/ProjectMembers.tsx create mode 100644 frontend/src/component/project/ProjectCard/ProjectCardFooter/ProjectOwners/ProjectOwners.tsx create mode 100644 frontend/src/component/project/ProjectCard/ProjectLastSeen/ProjectLastSeen.tsx diff --git a/frontend/src/component/project/ProjectCard/ProjectCardFooter/FavoriteAction/FavoriteAction.tsx b/frontend/src/component/project/ProjectCard/FavoriteAction/FavoriteAction.tsx similarity index 85% rename from frontend/src/component/project/ProjectCard/ProjectCardFooter/FavoriteAction/FavoriteAction.tsx rename to frontend/src/component/project/ProjectCard/FavoriteAction/FavoriteAction.tsx index 938c0575d4..f859151f41 100644 --- a/frontend/src/component/project/ProjectCard/ProjectCardFooter/FavoriteAction/FavoriteAction.tsx +++ b/frontend/src/component/project/ProjectCard/FavoriteAction/FavoriteAction.tsx @@ -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 = ({ id, isFavorite }) => { const { setToastApiError } = useToast(); const { favorite, unfavorite } = useFavoriteProjectsApi(); @@ -31,7 +26,7 @@ export const FavoriteAction: FC = ({ id, isFavorite }) => { }; return ( - 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) => ( @@ -79,7 +68,9 @@ export const ProjectCard = ({ - + ({ margin: theme.spacing(1, 2, 0, 0) })}> + + ); diff --git a/frontend/src/component/project/ProjectCard/ProjectOwners/ProjectOwners.tsx b/frontend/src/component/project/ProjectCard/LegacyProjectOwners/LegacyProjectOwners.tsx similarity index 100% rename from frontend/src/component/project/ProjectCard/ProjectOwners/ProjectOwners.tsx rename to frontend/src/component/project/ProjectCard/LegacyProjectOwners/LegacyProjectOwners.tsx diff --git a/frontend/src/component/project/ProjectCard/ProjectArchiveCard.tsx b/frontend/src/component/project/ProjectCard/ProjectArchiveCard.tsx index 2390868722..ab98f6427f 100644 --- a/frontend/src/component/project/ProjectCard/ProjectArchiveCard.tsx +++ b/frontend/src/component/project/ProjectCard/ProjectArchiveCard.tsx @@ -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 = ({ id, name, archivedAt, - archivedFeaturesCount, onRevive, onDelete, mode, diff --git a/frontend/src/component/project/ProjectCard/ProjectCard.styles.ts b/frontend/src/component/project/ProjectCard/ProjectCard.styles.ts index dec78a21f4..e3d989751f 100644 --- a/frontend/src/component/project/ProjectCard/ProjectCard.styles.ts +++ b/frontend/src/component/project/ProjectCard/ProjectCard.styles.ts @@ -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, diff --git a/frontend/src/component/project/ProjectCard/ProjectCard.tsx b/frontend/src/component/project/ProjectCard/ProjectCard.tsx index e8e88c06be..033d5bf90d 100644 --- a/frontend/src/component/project/ProjectCard/ProjectCard.tsx +++ b/frontend/src/component/project/ProjectCard/ProjectCard.tsx @@ -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) => ( - - {name} - + ({ + ...flexColumn, + margin: theme.spacing(1, 'auto', 1, 0), + })} + > + + {name} + + + Updated + + } + /> + + - +
- - {featureCount} - -

{featureCount === 1 ? 'flag' : 'flags'}

+
+ {featureCount} flag + {featureCount === 1 ? '' : 's'} +
+
+ {health}% health +
- - - {memberCount} - -

- {memberCount === 1 ? 'member' : 'members'} -

- - } - /> -
- - {health}% - -

healthy

-
-
+ +
- - - +
); diff --git a/frontend/src/component/project/ProjectCard/ProjectCardFooter/ProjectCardFooter.tsx b/frontend/src/component/project/ProjectCard/ProjectCardFooter/ProjectCardFooter.tsx index b1947feb00..f6f7df5cfc 100644 --- a/frontend/src/component/project/ProjectCard/ProjectCardFooter/ProjectCardFooter.tsx +++ b/frontend/src/component/project/ProjectCard/ProjectCardFooter/ProjectCardFooter.tsx @@ -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 = ({ + id, children, owners, disabled = false, + memberCount, }) => { + const projectListImprovementsEnabled = useUiFlag('projectListImprovements'); + return ( - - {children} + } + elseShow={} + /> + } + elseShow={children} + /> ); }; diff --git a/frontend/src/component/project/ProjectCard/ProjectCardFooter/ProjectMembers/ProjectMembers.tsx b/frontend/src/component/project/ProjectCard/ProjectCardFooter/ProjectMembers/ProjectMembers.tsx new file mode 100644 index 0000000000..6928651af3 --- /dev/null +++ b/frontend/src/component/project/ProjectCard/ProjectCardFooter/ProjectMembers/ProjectMembers.tsx @@ -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 }) => ( + +); + +export const ProjectMembers: FC = ({ + count = 0, + members, +}) => { + return ( + + + {count} member + {count === 1 ? '' : 's'} + + + + ); +}; diff --git a/frontend/src/component/project/ProjectCard/ProjectCardFooter/ProjectOwners/ProjectOwners.tsx b/frontend/src/component/project/ProjectCard/ProjectCardFooter/ProjectOwners/ProjectOwners.tsx new file mode 100644 index 0000000000..c4f69cea5f --- /dev/null +++ b/frontend/src/component/project/ProjectCard/ProjectCardFooter/ProjectOwners/ProjectOwners.tsx @@ -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 = ({ owners = [] }) => { + const ownersMap = useOwnersMap(); + const users = owners.map(ownersMap); + + return ( + + + + + + Owner + {users[0]?.name} + + } + /> + + ); +}; diff --git a/frontend/src/component/project/ProjectCard/ProjectLastSeen/ProjectLastSeen.tsx b/frontend/src/component/project/ProjectCard/ProjectLastSeen/ProjectLastSeen.tsx new file mode 100644 index 0000000000..5a70c85241 --- /dev/null +++ b/frontend/src/component/project/ProjectCard/ProjectLastSeen/ProjectLastSeen.tsx @@ -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 = () => ( + <> + ({ fontSize: theme.fontSizes.smallBody })} + > + Last usage reported + + ({ + 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. + + +); + +export const ProjectLastSeen: FC = ({ date }) => { + const getColor = useLastSeenColors(); + const { text, background } = getColor(date); + + if (!date) { + return ( + } arrow> + ({ color: theme.palette.text.secondary })} + > + + + {' '} +
No activity
+
+
+ ); + } + + return ( + } arrow> + + + + {' '} + + + + ); +}; diff --git a/frontend/src/component/project/ProjectCard/ProjectModeBadge/ProjectModeBadge.tsx b/frontend/src/component/project/ProjectCard/ProjectModeBadge/ProjectModeBadge.tsx index 243b345441..d2796b2657 100644 --- a/frontend/src/component/project/ProjectCard/ProjectModeBadge/ProjectModeBadge.tsx +++ b/frontend/src/component/project/ProjectCard/ProjectModeBadge/ProjectModeBadge.tsx @@ -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 = ({ mode }) => { + const projectListImprovementsEnabled = useUiFlag('projectListImprovements'); + if (mode === 'private') { + if (projectListImprovementsEnabled) { + return ( + + + + + + ); + } return ( = ({ mode }) => { } if (mode === 'protected') { + if (projectListImprovementsEnabled) { + return ( + + + + + + ); + } return ( ({ display: 'grid', @@ -24,25 +26,30 @@ const StyledCardLink = styled(Link)(({ theme }) => ({ pointer: 'cursor', })); -type ProjectGroupProps = { +type ProjectGroupProps = { sectionTitle?: string; - projects: T[]; + projects: IProjectCard[]; loading: boolean; searchValue: string; placeholder?: string; - ProjectCardComponent?: ComponentType; + ProjectCardComponent?: ComponentType; link?: boolean; }; -export const ProjectGroup = ({ +export const ProjectGroup = ({ sectionTitle, projects, loading, searchValue, placeholder = 'No projects available.', - ProjectCardComponent = LegacyProjectCard, + ProjectCardComponent, link = true, -}: ProjectGroupProps) => { +}: ProjectGroupProps) => { + const projectListImprovementsEnabled = useUiFlag('projectListImprovements'); + const ProjectCard = + ProjectCardComponent ?? + (projectListImprovementsEnabled ? NewProjectCard : LegacyProjectCard); + return (
({ <> {loadingData.map( (project: IProjectCard) => ( - {}} + createdAt={project.createdAt} key={project.id} name={project.name} id={project.id} @@ -99,21 +106,17 @@ export const ProjectGroup = ({ )} elseShow={() => ( <> - {projects.map((project: T) => + {projects.map((project) => link ? ( - {}} - {...project} - /> + ) : ( - {}} {...project} /> ), diff --git a/frontend/src/interfaces/project.ts b/frontend/src/interfaces/project.ts index d2b0e69faa..dc04874446 100644 --- a/frontend/src/interfaces/project.ts +++ b/frontend/src/interfaces/project.ts @@ -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 = {