diff --git a/frontend/src/component/project/NewProjectCard/NewProjectCard.styles.ts b/frontend/src/component/project/NewProjectCard/NewProjectCard.styles.ts new file mode 100644 index 0000000000..427e42dace --- /dev/null +++ b/frontend/src/component/project/NewProjectCard/NewProjectCard.styles.ts @@ -0,0 +1,77 @@ +import { styled } from '@mui/material'; +import { Card, Box } from '@mui/material'; +import Delete from '@mui/icons-material/Delete'; +import Edit from '@mui/icons-material/Edit'; +import { flexRow } from 'themes/themeStyles'; + +export const StyledProjectCard = styled(Card)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + height: '100%', + boxShadow: 'none', + border: `1px solid ${theme.palette.divider}`, + [theme.breakpoints.down('sm')]: { + justifyContent: 'center', + }, + '&:hover': { + transition: 'background-color 0.2s ease-in-out', + backgroundColor: theme.palette.neutral.light, + }, + borderRadius: theme.shape.borderRadiusMedium, +})); + +export const StyledProjectCardBody = styled(Box)(({ theme }) => ({ + padding: theme.spacing(1, 2, 2, 2), +})); + +export const StyledDivHeader = styled('div')(({ theme }) => ({ + ...flexRow, + width: '100%', + marginBottom: theme.spacing(2), +})); + +export const StyledH2Title = styled('h2')(({ theme }) => ({ + fontWeight: 'normal', + fontSize: theme.fontSizes.bodySize, + 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 StyledBox = styled(Box)(() => ({ + ...flexRow, + marginRight: 'auto', +})); + +export const StyledEditIcon = styled(Edit)(({ theme }) => ({ + color: theme.palette.neutral.main, + marginRight: theme.spacing(1), +})); + +export const StyledDeleteIcon = styled(Delete)(({ theme }) => ({ + color: theme.palette.neutral.main, + marginRight: theme.spacing(1), +})); + +export const StyledDivInfo = styled('div')(({ theme }) => ({ + display: 'flex', + justifyContent: 'space-between', + fontSize: theme.fontSizes.smallerBody, +})); + +export const StyledDivInfoContainer = styled('div')(() => ({ + textAlign: 'center', +})); + +export const StyledParagraphInfo = styled('p')(({ theme }) => ({ + color: theme.palette.primary.dark, + fontWeight: 'bold', +})); diff --git a/frontend/src/component/project/NewProjectCard/NewProjectCard.tsx b/frontend/src/component/project/NewProjectCard/NewProjectCard.tsx new file mode 100644 index 0000000000..132da6df83 --- /dev/null +++ b/frontend/src/component/project/NewProjectCard/NewProjectCard.tsx @@ -0,0 +1,143 @@ +import type React from 'react'; +import { Menu, MenuItem } from '@mui/material'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import { type SyntheticEvent, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { getProjectEditPath } from 'utils/routePathHelpers'; +import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; +import { UPDATE_PROJECT } from 'component/providers/AccessProvider/permissions'; +import { DEFAULT_PROJECT_ID } from 'hooks/api/getters/useDefaultProject/useDefaultProjectId'; +import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { DeleteProjectDialogue } from '../Project/DeleteProject/DeleteProjectDialogue'; +import { + StyledProjectCard, + StyledDivHeader, + StyledBox, + StyledH2Title, + StyledEditIcon, + StyledDivInfo, + StyledDivInfoContainer, + StyledParagraphInfo, + StyledProjectCardBody, +} from './NewProjectCard.styles'; +import { ProjectCardFooter } from './ProjectCardFooter/ProjectCardFooter'; +import { ProjectCardIcon } from './ProjectCardIcon/ProjectCardIcon'; + +interface IProjectCardProps { + name: string; + featureCount: number; + health: number; + memberCount: number; + id: string; + onHover: () => void; + isFavorite?: boolean; + mode: string; +} + +export const ProjectCard = ({ + name, + featureCount, + health, + memberCount, + onHover, + id, + mode, + isFavorite = false, +}: IProjectCardProps) => { + const { isOss } = useUiConfig(); + const [anchorEl, setAnchorEl] = useState(null); + const [showDelDialog, setShowDelDialog] = useState(false); + const navigate = useNavigate(); + + const handleClick = (event: React.SyntheticEvent) => { + event.preventDefault(); + setAnchorEl(event.currentTarget); + }; + + return ( + + + + + + {name} + + + + { + event.preventDefault(); + }} + onClose={(event: SyntheticEvent) => { + event.preventDefault(); + setAnchorEl(null); + }} + > + { + e.preventDefault(); + navigate(getProjectEditPath(id)); + }} + > + + Edit project + + + + + + + {featureCount} + +

toggles

+
+ + + {health}% + +

health

+
+ + + + {memberCount} + +

members

+ + } + /> +
+
+ + { + e.preventDefault(); + setAnchorEl(null); + setShowDelDialog(false); + }} + /> +
+ ); +}; diff --git a/frontend/src/component/project/NewProjectCard/ProjectCardFooter/ProjectCardFooter.tsx b/frontend/src/component/project/NewProjectCard/ProjectCardFooter/ProjectCardFooter.tsx new file mode 100644 index 0000000000..66a051c4e0 --- /dev/null +++ b/frontend/src/component/project/NewProjectCard/ProjectCardFooter/ProjectCardFooter.tsx @@ -0,0 +1,58 @@ +import type { VFC } from 'react'; +import { Box, styled } from '@mui/material'; +import { FavoriteIconButton } from 'component/common/FavoriteIconButton/FavoriteIconButton'; +import useToast from 'hooks/useToast'; +import { useFavoriteProjectsApi } from 'hooks/api/actions/useFavoriteProjectsApi/useFavoriteProjectsApi'; +import useProjects from 'hooks/api/getters/useProjects/useProjects'; + +interface IProjectCardFooterProps { + id: string; + isFavorite?: boolean; +} + +const StyledFooter = styled(Box)(({ theme }) => ({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: theme.spacing(1, 2), + borderTop: `1px solid ${theme.palette.grey[300]}`, + backgroundColor: theme.palette.grey[100], + boxShadow: 'inset 0px 2px 4px rgba(32, 32, 33, 0.05)', // FIXME: replace with variable +})); + +const StyledFavoriteIconButton = styled(FavoriteIconButton)(({ theme }) => ({ + marginRight: theme.spacing(-1), + marginLeft: 'auto', +})); + +export const ProjectCardFooter: VFC = ({ + id, + isFavorite = false, +}) => { + const { setToastApiError } = useToast(); + const { favorite, unfavorite } = useFavoriteProjectsApi(); + const { refetch } = useProjects(); + + const onFavorite = async (e: React.SyntheticEvent) => { + e.preventDefault(); + try { + if (isFavorite) { + await unfavorite(id); + } else { + await favorite(id); + } + refetch(); + } catch (error) { + setToastApiError('Something went wrong, could not update favorite'); + } + }; + return ( + + + + ); +}; diff --git a/frontend/src/component/project/NewProjectCard/ProjectCardIcon/ProjectCardIcon.tsx b/frontend/src/component/project/NewProjectCard/ProjectCardIcon/ProjectCardIcon.tsx new file mode 100644 index 0000000000..af74cdac6c --- /dev/null +++ b/frontend/src/component/project/NewProjectCard/ProjectCardIcon/ProjectCardIcon.tsx @@ -0,0 +1,68 @@ +import type { VFC } from 'react'; +import { styled } from '@mui/material'; +import { Box } from '@mui/material'; +import BarChartIcon from '@mui/icons-material/BarChart'; +import LockIcon from '@mui/icons-material/Lock'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; +import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip'; + +interface IProjectCardIconProps { + mode: 'private' | 'protected' | 'public' | string; +} + +const StyledVisibilityIcon = styled(VisibilityOffIcon)(({ theme }) => ({ + color: theme.palette.action.disabled, +})); + +const StyledLockIcon = styled(LockIcon)(({ theme }) => ({ + color: theme.palette.action.disabled, +})); + +const StyledProjectIcon = styled(BarChartIcon)(({ theme }) => ({ + color: theme.palette.primary.main, +})); + +export const StyledIconBox = styled(Box)(({ theme }) => ({ + display: 'flex', + justifyContent: 'center', + borderWidth: '1px', + borderRadius: theme.shape.borderRadius, + borderStyle: 'solid', + borderColor: theme.palette.neutral.border, + padding: theme.spacing(0.5), + marginRight: theme.spacing(2), +})); + +export const ProjectCardIcon: VFC = ({ mode }) => { + if (mode === 'private') { + return ( + + + + + + ); + } + + if (mode === 'protected') { + return ( + + + + + + ); + } + + return ( + + + + ); +}; diff --git a/frontend/src/component/project/ProjectList/ProjectList.tsx b/frontend/src/component/project/ProjectList/ProjectList.tsx index 43db5f361b..6dada6d26e 100644 --- a/frontend/src/component/project/ProjectList/ProjectList.tsx +++ b/frontend/src/component/project/ProjectList/ProjectList.tsx @@ -4,7 +4,8 @@ import { mutate } from 'swr'; import { getProjectFetcher } from 'hooks/api/getters/useProject/getProjectFetcher'; import useProjects from 'hooks/api/getters/useProjects/useProjects'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -import { ProjectCard } from '../ProjectCard/ProjectCard'; +import { ProjectCard as LegacyProjectCard } from '../ProjectCard/ProjectCard'; +import { ProjectCard as NewProjectCard } from '../NewProjectCard/NewProjectCard'; import type { IProjectCard } from 'interfaces/project'; import loadingData from './loadingData'; import { PageContent } from 'component/common/PageContent/PageContent'; @@ -34,6 +35,9 @@ import { useUiFlag } from 'hooks/useUiFlag'; import { useProfile } from 'hooks/api/getters/useProfile/useProfile'; import { shouldDisplayInMyProjects } from './should-display-in-my-projects'; +/** + * @deprecated Remove after with `projectsListNewCards` flag + */ const StyledDivContainer = styled('div')(({ theme }) => ({ display: 'flex', flexWrap: 'wrap', @@ -42,6 +46,12 @@ const StyledDivContainer = styled('div')(({ theme }) => ({ }, })); +const StyledGridContainer = styled('div')(({ theme }) => ({ + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', + gap: theme.spacing(2), +})); + const StyledApiError = styled(ApiError)(({ theme }) => ({ maxWidth: '400px', marginBottom: theme.spacing(2), @@ -139,6 +149,7 @@ export const ProjectListNew = () => { ); const showProjectFilterButtons = useUiFlag('projectListFilterMyProjects'); + const projectsListNewCards = useUiFlag('projectsListNewCards'); const filters = ['All projects', 'My projects']; const [filter, setFilter] = useState(filters[0]); const myProjects = new Set(useProfile().profile?.projects || []); @@ -204,6 +215,13 @@ export const ProjectListNew = () => { ? `${filteredProjects.length} of ${projects.length}` : projects.length; + const StyledItemsContainer = projectsListNewCards + ? StyledGridContainer + : StyledDivContainer; + const ProjectCard = projectsListNewCards + ? NewProjectCard + : LegacyProjectCard; + return ( { } > - + { /> } /> - + ); }; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 2b16b3917b..c63f4422af 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -84,6 +84,7 @@ export type UiFlags = { scimApi?: boolean; projectListFilterMyProjects?: boolean; createProjectWithEnvironmentConfig?: boolean; + projectsListNewCards?: boolean; }; export interface IVersionInfo { diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index d7e22bc44e..c5be30ed30 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -59,6 +59,7 @@ export type IFlagKey = | 'projectOverviewRefactorFeedback' | 'featureLifecycle' | 'projectListFilterMyProjects' + | 'projectsListNewCards' | 'parseProjectFromSession' | 'createProjectWithEnvironmentConfig' | 'applicationOverviewNewQuery'; diff --git a/src/server-dev.ts b/src/server-dev.ts index 2c3b23a7f5..cf219d02fb 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -55,6 +55,7 @@ process.nextTick(async () => { projectOverviewRefactorFeedback: true, featureLifecycle: true, projectListFilterMyProjects: true, + projectsListNewCards: true, parseProjectFromSession: true, createProjectWithEnvironmentConfig: true, applicationOverviewNewQuery: true,