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

View File

@ -60,6 +60,7 @@ const StyledUserName = styled('span')(({ theme }) => ({
const StyledContainer = styled('div')(() => ({ const StyledContainer = styled('div')(() => ({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
borderRadius: '50%',
})); }));
const StyledOwnerName = styled('div')(({ theme }) => ({ const StyledOwnerName = styled('div')(({ theme }) => ({
@ -74,6 +75,7 @@ const StyledHeader = styled('span')(({ theme }) => ({
fontSize: theme.fontSizes.smallerBody, fontSize: theme.fontSizes.smallerBody,
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
fontWeight: theme.typography.fontWeightRegular, fontWeight: theme.typography.fontWeightRegular,
marginRight: 'auto',
})); }));
const StyledWrapper = styled('div')(({ theme }) => ({ const StyledWrapper = styled('div')(({ theme }) => ({
@ -92,10 +94,10 @@ export const ProjectOwners: FC<IProjectOwnersProps> = ({ owners = [] }) => {
return ( return (
<StyledWrapper data-testid='test'> <StyledWrapper data-testid='test'>
<StyledContainer> <StyledContainer data-loading>
<AvatarGroup <AvatarGroup
users={users} users={users}
avatarLimit={4} avatarLimit={6}
AvatarComponent={StyledAvatarComponent} AvatarComponent={StyledAvatarComponent}
/> />
</StyledContainer> </StyledContainer>
@ -103,8 +105,10 @@ export const ProjectOwners: FC<IProjectOwnersProps> = ({ owners = [] }) => {
condition={owners.length === 1} condition={owners.length === 1}
show={ show={
<StyledOwnerName> <StyledOwnerName>
<StyledHeader>Owner</StyledHeader> <StyledHeader data-loading>Owner</StyledHeader>
<StyledUserName>{users[0]?.name}</StyledUserName> <StyledUserName data-loading>
{users[0]?.name}
</StyledUserName>
</StyledOwnerName> </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 { Link } from 'react-router-dom';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { ProjectCard as LegacyProjectCard } from '../ProjectCard/LegacyProjectCard'; import { ProjectCard as LegacyProjectCard } from '../ProjectCard/LegacyProjectCard';
@ -10,6 +10,26 @@ import { TablePlaceholder } from 'component/common/Table';
import { styled, Typography } from '@mui/material'; import { styled, Typography } from '@mui/material';
import { useUiFlag } from 'hooks/useUiFlag'; import { useUiFlag } from 'hooks/useUiFlag';
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; 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 }) => ({ const StyledGridContainer = styled('div')(({ theme }) => ({
display: 'grid', display: 'grid',
@ -29,6 +49,8 @@ const StyledCardLink = styled(Link)(({ theme }) => ({
type ProjectGroupProps = { type ProjectGroupProps = {
sectionTitle?: string; sectionTitle?: string;
sectionSubtitle?: string;
HeaderActions?: ReactNode;
projects: IProjectCard[]; projects: IProjectCard[];
loading: boolean; loading: boolean;
/** /**
@ -42,6 +64,8 @@ type ProjectGroupProps = {
export const ProjectGroup = ({ export const ProjectGroup = ({
sectionTitle, sectionTitle,
sectionSubtitle,
HeaderActions,
projects, projects,
loading, loading,
searchValue, searchValue,
@ -56,19 +80,31 @@ export const ProjectGroup = ({
const { searchQuery } = useSearchHighlightContext(); const { searchQuery } = useSearchHighlightContext();
return ( return (
<article> <StyledContainer>
<StyledHeaderContainer>
<StyledHeaderTitle>
<ConditionallyRender <ConditionallyRender
condition={Boolean(sectionTitle)} condition={Boolean(sectionTitle)}
show={ show={
<Typography <Typography component='h2' variant='h2'>
component='h2'
variant='h3'
sx={(theme) => ({ marginBottom: theme.spacing(2) })}
>
{sectionTitle} {sectionTitle}
</Typography> </Typography>
} }
/> />
<ConditionallyRender
condition={
Boolean(sectionSubtitle) &&
projectListImprovementsEnabled
}
show={
<Typography variant='body2' color='text.secondary'>
{sectionSubtitle}
</Typography>
}
/>
</StyledHeaderTitle>
{HeaderActions}
</StyledHeaderContainer>
<ConditionallyRender <ConditionallyRender
condition={projects.length < 1 && !loading} condition={projects.length < 1 && !loading}
show={ show={
@ -104,6 +140,12 @@ export const ProjectGroup = ({
memberCount={2} memberCount={2}
health={95} health={95}
featureCount={4} featureCount={4}
owners={[
{
ownerType: 'user',
name: 'Loading data',
},
]}
/> />
), ),
)} )}
@ -132,6 +174,6 @@ export const ProjectGroup = ({
</StyledGridContainer> </StyledGridContainer>
} }
/> />
</article> </StyledContainer>
); );
}; };

View File

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

View File

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

View File

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