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:
parent
8b68a0657f
commit
07abb60966
@ -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
|
||||
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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' },
|
||||
];
|
||||
|
||||
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user