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:
parent
0572d37181
commit
fd4bcfffa5
@ -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',
|
||||
}));
|
143
frontend/src/component/project/NewProjectCard/NewProjectCard.tsx
Normal file
143
frontend/src/component/project/NewProjectCard/NewProjectCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -84,6 +84,7 @@ export type UiFlags = {
|
||||
scimApi?: boolean;
|
||||
projectListFilterMyProjects?: boolean;
|
||||
createProjectWithEnvironmentConfig?: boolean;
|
||||
projectsListNewCards?: boolean;
|
||||
};
|
||||
|
||||
export interface IVersionInfo {
|
||||
|
@ -59,6 +59,7 @@ export type IFlagKey =
|
||||
| 'projectOverviewRefactorFeedback'
|
||||
| 'featureLifecycle'
|
||||
| 'projectListFilterMyProjects'
|
||||
| 'projectsListNewCards'
|
||||
| 'parseProjectFromSession'
|
||||
| 'createProjectWithEnvironmentConfig'
|
||||
| 'applicationOverviewNewQuery';
|
||||
|
@ -55,6 +55,7 @@ process.nextTick(async () => {
|
||||
projectOverviewRefactorFeedback: true,
|
||||
featureLifecycle: true,
|
||||
projectListFilterMyProjects: true,
|
||||
projectsListNewCards: true,
|
||||
parseProjectFromSession: true,
|
||||
createProjectWithEnvironmentConfig: true,
|
||||
applicationOverviewNewQuery: true,
|
||||
|
Loading…
Reference in New Issue
Block a user