1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

Feat/improve projects list (#8018)

This commit is contained in:
Tymoteusz Czech 2024-08-29 16:39:44 +02:00 committed by GitHub
parent 8b68a0657f
commit 07abb60966
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 188 additions and 69 deletions

View File

@ -1,7 +1,5 @@
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import {
StyledProjectCard,
StyledDivHeader,
StyledCardTitle,
StyledProjectCardBody,
StyledIconBox,
@ -28,11 +26,19 @@ const StyledCount = styled('strong')(({ theme }) => ({
}));
const StyledInfo = styled('div')(({ theme }) => ({
display: 'flex',
display: 'grid',
gridTemplate: '1rem 1rem / 1fr 1fr',
gridAutoFlow: 'column',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: theme.spacing(1),
fontSize: theme.fontSizes.smallerBody,
alignItems: 'flex-end',
}));
const StyledHeader = styled('div')(({ theme }) => ({
gap: theme.spacing(1),
display: 'flex',
width: '100%',
}));
export const ProjectCard = ({
@ -45,6 +51,7 @@ export const ProjectCard = ({
mode,
favorite = false,
owners,
createdAt,
lastUpdatedAt,
lastReportedFlagUsage,
}: IProjectCard) => {
@ -53,7 +60,7 @@ export const ProjectCard = ({
return (
<StyledProjectCard onMouseEnter={onHover}>
<StyledProjectCardBody>
<StyledDivHeader>
<StyledHeader>
<StyledIconBox>
<ProjectIcon />
</StyledIconBox>
@ -69,29 +76,26 @@ export const ProjectCard = ({
{name}
</Highlighter>
</StyledCardTitle>
<ConditionallyRender
condition={Boolean(lastUpdatedAt)}
show={
<StyledUpdated>
Updated <TimeAgo date={lastUpdatedAt} />
</StyledUpdated>
}
/>
<StyledUpdated>
Updated{' '}
<TimeAgo date={lastUpdatedAt || createdAt} />
</StyledUpdated>
</Box>
<ProjectModeBadge mode={mode} />
<FavoriteAction id={id} isFavorite={favorite} />
</StyledDivHeader>
</StyledHeader>
<StyledInfo>
<div>
<div>
<StyledCount>{featureCount}</StyledCount> flag
{featureCount === 1 ? '' : 's'}
</div>
<div>
<StyledCount>{health}%</StyledCount> health
</div>
<div data-loading>
<StyledCount>{featureCount}</StyledCount> flag
{featureCount === 1 ? '' : 's'}
</div>
<div data-loading>
<StyledCount>{health}%</StyledCount> health
</div>
<div />
<div data-loading>
<ProjectLastSeen date={lastReportedFlagUsage} />
</div>
<ProjectLastSeen date={lastReportedFlagUsage} />
</StyledInfo>
</StyledProjectCardBody>
<ProjectCardFooter

View File

@ -60,6 +60,7 @@ const StyledUserName = styled('span')(({ theme }) => ({
const StyledContainer = styled('div')(() => ({
display: 'flex',
flexDirection: 'column',
borderRadius: '50%',
}));
const StyledOwnerName = styled('div')(({ theme }) => ({
@ -74,6 +75,7 @@ const StyledHeader = styled('span')(({ theme }) => ({
fontSize: theme.fontSizes.smallerBody,
color: theme.palette.text.secondary,
fontWeight: theme.typography.fontWeightRegular,
marginRight: 'auto',
}));
const StyledWrapper = styled('div')(({ theme }) => ({
@ -92,10 +94,10 @@ export const ProjectOwners: FC<IProjectOwnersProps> = ({ owners = [] }) => {
return (
<StyledWrapper data-testid='test'>
<StyledContainer>
<StyledContainer data-loading>
<AvatarGroup
users={users}
avatarLimit={4}
avatarLimit={6}
AvatarComponent={StyledAvatarComponent}
/>
</StyledContainer>
@ -103,8 +105,10 @@ export const ProjectOwners: FC<IProjectOwnersProps> = ({ owners = [] }) => {
condition={owners.length === 1}
show={
<StyledOwnerName>
<StyledHeader>Owner</StyledHeader>
<StyledUserName>{users[0]?.name}</StyledUserName>
<StyledHeader data-loading>Owner</StyledHeader>
<StyledUserName data-loading>
{users[0]?.name}
</StyledUserName>
</StyledOwnerName>
}
/>

View File

@ -0,0 +1,38 @@
import {
IconButton,
Link,
Tooltip,
useMediaQuery,
useTheme,
} from '@mui/material';
import ArchiveIcon from '@mui/icons-material/Inventory2Outlined';
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import type { FC } from 'react';
export const ProjectArchiveLink: FC = () => {
const navigate = useNavigate();
const theme = useTheme();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
if (isSmallScreen) {
return (
<Tooltip arrow title='See archived projects'>
<IconButton
onClick={() => navigate('/projects-archive')}
data-loading
>
<ArchiveIcon />
</IconButton>
</Tooltip>
);
}
return (
<>
<Link component={RouterLink} to='/projects-archive' data-loading>
Archived projects
</Link>
<PageHeader.Divider />
</>
);
};

View File

@ -1,4 +1,4 @@
import type { ComponentType } from 'react';
import type { ComponentType, ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { ProjectCard as LegacyProjectCard } from '../ProjectCard/LegacyProjectCard';
@ -10,6 +10,26 @@ import { TablePlaceholder } from 'component/common/Table';
import { styled, Typography } from '@mui/material';
import { useUiFlag } from 'hooks/useUiFlag';
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { flexColumn } from 'themes/themeStyles';
const StyledContainer = styled('article')(({ theme }) => ({
...flexColumn,
gap: theme.spacing(2),
}));
const StyledHeaderContainer = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column-reverse',
gap: theme.spacing(2),
[theme.breakpoints.up('md')]: {
flexDirection: 'row',
alignItems: 'flex-end',
},
}));
const StyledHeaderTitle = styled('div')(() => ({
flexGrow: 0,
}));
const StyledGridContainer = styled('div')(({ theme }) => ({
display: 'grid',
@ -29,6 +49,8 @@ const StyledCardLink = styled(Link)(({ theme }) => ({
type ProjectGroupProps = {
sectionTitle?: string;
sectionSubtitle?: string;
HeaderActions?: ReactNode;
projects: IProjectCard[];
loading: boolean;
/**
@ -42,6 +64,8 @@ type ProjectGroupProps = {
export const ProjectGroup = ({
sectionTitle,
sectionSubtitle,
HeaderActions,
projects,
loading,
searchValue,
@ -56,19 +80,31 @@ export const ProjectGroup = ({
const { searchQuery } = useSearchHighlightContext();
return (
<article>
<ConditionallyRender
condition={Boolean(sectionTitle)}
show={
<Typography
component='h2'
variant='h3'
sx={(theme) => ({ marginBottom: theme.spacing(2) })}
>
{sectionTitle}
</Typography>
}
/>
<StyledContainer>
<StyledHeaderContainer>
<StyledHeaderTitle>
<ConditionallyRender
condition={Boolean(sectionTitle)}
show={
<Typography component='h2' variant='h2'>
{sectionTitle}
</Typography>
}
/>
<ConditionallyRender
condition={
Boolean(sectionSubtitle) &&
projectListImprovementsEnabled
}
show={
<Typography variant='body2' color='text.secondary'>
{sectionSubtitle}
</Typography>
}
/>
</StyledHeaderTitle>
{HeaderActions}
</StyledHeaderContainer>
<ConditionallyRender
condition={projects.length < 1 && !loading}
show={
@ -104,6 +140,12 @@ export const ProjectGroup = ({
memberCount={2}
health={95}
featureCount={4}
owners={[
{
ownerType: 'user',
name: 'Loading data',
},
]}
/>
),
)}
@ -132,6 +174,6 @@ export const ProjectGroup = ({
</StyledGridContainer>
}
/>
</article>
</StyledContainer>
);
};

View File

@ -4,8 +4,7 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import ApiError from 'component/common/ApiError/ApiError';
import { Link, styled, useMediaQuery } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
import { styled, useMediaQuery } from '@mui/material';
import theme from 'themes/theme';
import { Search } from 'component/common/Search/Search';
import { useProfile } from 'hooks/api/getters/useProfile/useProfile';
@ -18,6 +17,7 @@ import { ProjectList as LegacyProjectList } from './LegacyProjectList';
import { ProjectCreationButton } from './ProjectCreationButton/ProjectCreationButton';
import { useGroupedProjects } from './hooks/useGroupedProjects';
import { useProjectsSearchAndSort } from './hooks/useProjectsSearchAndSort';
import { ProjectArchiveLink } from './ProjectArchiveLink/ProjectArchiveLink';
const StyledApiError = styled(ApiError)(({ theme }) => ({
maxWidth: '500px',
@ -27,7 +27,7 @@ const StyledApiError = styled(ApiError)(({ theme }) => ({
const StyledContainer = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(4),
gap: theme.spacing(6),
}));
const NewProjectList = () => {
@ -77,19 +77,10 @@ const NewProjectList = () => {
</>
}
/>
<ConditionallyRender
condition={Boolean(archiveProjectsEnabled)}
show={
<>
<Link
component={RouterLink}
to='/projects-archive'
>
Archived projects
</Link>
<PageHeader.Divider />
</>
}
show={<ProjectArchiveLink />}
/>
<ProjectCreationButton
isDialogOpen={Boolean(state.create)}
@ -124,21 +115,27 @@ const NewProjectList = () => {
/>
)}
/>
<ProjectsListSort
sortBy={state.sortBy}
setSortBy={(sortBy) =>
setState({ sortBy: sortBy as typeof state.sortBy })
}
/>
<SearchHighlightProvider value={state.query || ''}>
<ProjectGroup
sectionTitle='My projects'
sectionSubtitle='Favorite projects, projects you own or projects you are a member of.'
HeaderActions={
<ProjectsListSort
sortBy={state.sortBy}
setSortBy={(sortBy) =>
setState({
sortBy: sortBy as typeof state.sortBy,
})
}
/>
}
loading={loading}
projects={groupedProjects.myProjects}
/>
<ProjectGroup
sectionTitle='Other projects'
sectionSubtitle='Projects in Unleash that you have access to.'
loading={loading}
projects={groupedProjects.otherProjects}
/>

View File

@ -5,7 +5,7 @@ import { styled } from '@mui/material';
const StyledWrapper = styled('div')(({ theme }) => ({
display: 'flex',
justifyContent: 'flex-end',
margin: theme.spacing(0, 0, -4, 0),
flex: 1,
}));
const StyledContainer = styled('div')(() => ({
@ -21,7 +21,7 @@ const options: Array<{
}> = [
{ key: 'name', label: 'Project name' },
{ key: 'created', label: 'Recently created' },
{ key: 'updated', label: 'Recently updated' },
{ key: 'updated', label: 'Last updated' },
{ key: 'seen', label: 'Last usage reported' },
];

View File

@ -219,4 +219,35 @@ describe('useProjectsSearchAndSort', () => {
'Project A',
]);
});
it('should use createdAt if lastUpdatedAt is not available', () => {
const { result } = renderHook(
(sortBy: string) =>
useProjectsSearchAndSort(
[
{
name: 'Project A',
id: '1',
createdAt: '2024-01-01',
lastUpdatedAt: '2024-01-02',
},
{
name: 'Project B',
id: '2',
createdAt: '2024-02-01',
},
],
undefined,
sortBy as any,
),
{
initialProps: 'updated',
},
);
expect(result.current.map((project) => project.name)).toEqual([
'Project B',
'Project A',
]);
});
});

View File

@ -15,6 +15,11 @@ export const useProjectsSearchAndSort = (
? projects.filter((project) => regExp.test(project.name))
: projects
)
.sort((a, b) => {
const aVal = `${a.name || ''}`.toLowerCase();
const bVal = `${b.name || ''}`.toLowerCase();
return aVal?.localeCompare(bVal);
})
.sort((a, b) => {
if (sortBy === 'created') {
const aVal = new Date(a.createdAt || 0);
@ -23,8 +28,8 @@ export const useProjectsSearchAndSort = (
}
if (sortBy === 'updated') {
const aVal = new Date(a.lastUpdatedAt || 0);
const bVal = new Date(b.lastUpdatedAt || 0);
const aVal = new Date(a.lastUpdatedAt || a.createdAt || 0);
const bVal = new Date(b.lastUpdatedAt || b.createdAt || 0);
return bVal?.getTime() - aVal?.getTime();
}
@ -34,9 +39,7 @@ export const useProjectsSearchAndSort = (
return bVal?.getTime() - aVal?.getTime();
}
const aVal = `${a.name || ''}`.toLowerCase();
const bVal = `${b.name || ''}`.toLowerCase();
return aVal?.localeCompare(bVal);
return 0;
})
.sort((a, b) => {
if (a?.favorite && !b?.favorite) {