1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-15 01:16:22 +02:00

Feat: new projects list (#6873)

New card view for list of projects.

Co-authored-by: Thomas Heartman <thomas@getunleash.io>
This commit is contained in:
Tymoteusz Czech 2024-04-18 11:20:01 +02:00 committed by GitHub
parent 0572d37181
commit fd4bcfffa5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 370 additions and 3 deletions

View File

@ -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',
}));

View File

@ -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<Element | null>(null);
const [showDelDialog, setShowDelDialog] = useState(false);
const navigate = useNavigate();
const handleClick = (event: React.SyntheticEvent) => {
event.preventDefault();
setAnchorEl(event.currentTarget);
};
return (
<StyledProjectCard onMouseEnter={onHover}>
<StyledProjectCardBody>
<StyledDivHeader data-loading>
<ProjectCardIcon mode={mode} />
<StyledBox>
<StyledH2Title>{name}</StyledH2Title>
</StyledBox>
<PermissionIconButton
style={{ transform: 'translateX(7px)' }}
permission={UPDATE_PROJECT}
hidden={isOss()}
projectId={id}
data-loading
onClick={handleClick}
tooltipProps={{
title: 'Options',
}}
>
<MoreVertIcon />
</PermissionIconButton>
<Menu
id='project-card-menu'
open={Boolean(anchorEl)}
anchorEl={anchorEl}
style={{ top: 0, left: -100 }}
onClick={(event) => {
event.preventDefault();
}}
onClose={(event: SyntheticEvent) => {
event.preventDefault();
setAnchorEl(null);
}}
>
<MenuItem
onClick={(e) => {
e.preventDefault();
navigate(getProjectEditPath(id));
}}
>
<StyledEditIcon />
Edit project
</MenuItem>
</Menu>
</StyledDivHeader>
<StyledDivInfo>
<StyledDivInfoContainer>
<StyledParagraphInfo data-loading>
{featureCount}
</StyledParagraphInfo>
<p data-loading>toggles</p>
</StyledDivInfoContainer>
<StyledDivInfoContainer>
<StyledParagraphInfo data-loading>
{health}%
</StyledParagraphInfo>
<p data-loading>health</p>
</StyledDivInfoContainer>
<ConditionallyRender
condition={id !== DEFAULT_PROJECT_ID}
show={
<StyledDivInfoContainer>
<StyledParagraphInfo data-loading>
{memberCount}
</StyledParagraphInfo>
<p data-loading>members</p>
</StyledDivInfoContainer>
}
/>
</StyledDivInfo>
</StyledProjectCardBody>
<ProjectCardFooter id={id} isFavorite={isFavorite} />
<DeleteProjectDialogue
project={id}
open={showDelDialog}
onClose={(e) => {
e.preventDefault();
setAnchorEl(null);
setShowDelDialog(false);
}}
/>
</StyledProjectCard>
);
};

View File

@ -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<IProjectCardFooterProps> = ({
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 (
<StyledFooter>
<StyledFavoriteIconButton
onClick={onFavorite}
isFavorite={isFavorite}
size='medium'
/>
</StyledFooter>
);
};

View File

@ -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<IProjectCardIconProps> = ({ mode }) => {
if (mode === 'private') {
return (
<StyledIconBox data-loading>
<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
>
<StyledVisibilityIcon />
</HtmlTooltip>
</StyledIconBox>
);
}
if (mode === 'protected') {
return (
<StyledIconBox data-loading>
<HtmlTooltip
title="This project's collaboration mode is set to protected. Only admins and project members can submit change requests."
arrow
>
<StyledLockIcon />
</HtmlTooltip>
</StyledIconBox>
);
}
return (
<StyledIconBox data-loading>
<StyledProjectIcon />
</StyledIconBox>
);
};

View File

@ -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 (
<PageContent
isLoading={loading}
@ -282,7 +300,7 @@ export const ProjectListNew = () => {
}
>
<ConditionallyRender condition={error} show={renderError()} />
<StyledDivContainer>
<StyledItemsContainer>
<ConditionallyRender
condition={filteredProjects.length < 1 && !loading}
show={
@ -350,7 +368,7 @@ export const ProjectListNew = () => {
/>
}
/>
</StyledDivContainer>
</StyledItemsContainer>
</PageContent>
);
};

View File

@ -84,6 +84,7 @@ export type UiFlags = {
scimApi?: boolean;
projectListFilterMyProjects?: boolean;
createProjectWithEnvironmentConfig?: boolean;
projectsListNewCards?: boolean;
};
export interface IVersionInfo {

View File

@ -59,6 +59,7 @@ export type IFlagKey =
| 'projectOverviewRefactorFeedback'
| 'featureLifecycle'
| 'projectListFilterMyProjects'
| 'projectsListNewCards'
| 'parseProjectFromSession'
| 'createProjectWithEnvironmentConfig'
| 'applicationOverviewNewQuery';

View File

@ -55,6 +55,7 @@ process.nextTick(async () => {
projectOverviewRefactorFeedback: true,
featureLifecycle: true,
projectListFilterMyProjects: true,
projectsListNewCards: true,
parseProjectFromSession: true,
createProjectWithEnvironmentConfig: true,
applicationOverviewNewQuery: true,