1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-09 13:47:13 +02:00

chore: overhaul project list pages (#10447)

https://linear.app/unleash/issue/2-3743/overhaul-project-list-pages

This is essentially scouting work on our project list pages so we have a
better baseline before proceeding with the cards/list view toggle.

Includes refactoring, fixes and visual improvements ensuring better
consistency and alignment with our designs.

Took some liberties, so feel free to tell me I'm wrong.

### Searching

<img width="1224" height="667" alt="image"
src="https://github.com/user-attachments/assets/3f1bf700-7323-4c00-81db-7b57d125810b"
/>

### Search results only in one of the groups

<img width="1216" height="384" alt="image"
src="https://github.com/user-attachments/assets/f67536e3-42de-4371-9725-c38a6fe0889a"
/>

### No results found

<img width="1218" height="347" alt="image"
src="https://github.com/user-attachments/assets/c15c3555-1f37-473e-8a3e-8a549bd24966"
/>

### Helper text

<img width="334" height="114" alt="image"
src="https://github.com/user-attachments/assets/c9150c9c-22c6-4f73-8989-b9cba4b52793"
/>

### Title truncation with tooltip

<img width="333" height="192" alt="image"
src="https://github.com/user-attachments/assets/1f88d82d-67b2-4327-9301-808fef1e11ac"
/>

### Archived projects

<img width="1075" height="351" alt="image"
src="https://github.com/user-attachments/assets/87b10618-b7c4-4351-87d3-3e678ddd20ae"
/>
This commit is contained in:
Nuno Góis 2025-08-01 09:46:48 +01:00 committed by GitHub
parent ddd503952b
commit 0ac997e63e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 236 additions and 319 deletions

View File

@ -1,10 +1,11 @@
import type { FC } from 'react';
import {
StyledProjectCard,
StyledBox,
StyledCardTitle,
StyledProjectCardTitle,
StyledProjectCardBody,
StyledActions,
StyledProjectCardHeader,
StyledProjectCardContent,
StyledProjectCardTitleContainer,
} from './ProjectCard.styles';
import { ProjectCardFooter } from './ProjectCardFooter/ProjectCardFooter.tsx';
import { ProjectModeBadge } from './ProjectModeBadge/ProjectModeBadge.tsx';
@ -12,8 +13,8 @@ import type { ProjectSchemaOwners } from 'openapi';
import { formatDateYMDHM } from 'utils/formatDate';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { parseISO } from 'date-fns';
import { Box, Link, styled, Tooltip } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
import { Box, styled, Tooltip } from '@mui/material';
import { Link } from 'react-router-dom';
import {
DELETE_PROJECT,
UPDATE_PROJECT,
@ -24,7 +25,12 @@ import Delete from '@mui/icons-material/Delete';
import { Highlighter } from 'component/common/Highlighter/Highlighter';
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { TimeAgo } from 'component/common/TimeAgo/TimeAgo';
import { flexRow } from 'themes/themeStyles';
import { Truncator } from 'component/common/Truncator/Truncator.tsx';
const StyledActions = styled(Box)(({ theme }) => ({
display: 'flex',
gap: theme.spacing(1),
}));
export type ProjectArchiveCardProps = {
id: string;
@ -36,24 +42,6 @@ export type ProjectArchiveCardProps = {
owners?: ProjectSchemaOwners;
};
export const StyledDivHeader = styled('div')(({ theme }) => ({
...flexRow,
width: '100%',
gap: theme.spacing(1),
minHeight: theme.spacing(6),
marginBottom: theme.spacing(1),
}));
const StyledTitle = styled(StyledCardTitle)(({ theme }) => ({
margin: 0,
}));
const StyledContent = styled('div')(({ theme }) => ({
...flexRow,
fontSize: theme.fontSizes.smallerBody,
justifyContent: 'space-between',
}));
export const ProjectArchiveCard: FC<ProjectArchiveCardProps> = ({
id,
name,
@ -69,51 +57,48 @@ export const ProjectArchiveCard: FC<ProjectArchiveCardProps> = ({
return (
<StyledProjectCard disabled data-testid={id}>
<StyledProjectCardBody>
<StyledDivHeader>
<StyledBox data-loading>
<Tooltip title={`id: ${id}`} arrow>
<StyledTitle>
<Highlighter search={searchQuery}>
{name}
</Highlighter>
</StyledTitle>
</Tooltip>
</StyledBox>
<ProjectModeBadge mode={mode} />
</StyledDivHeader>
<StyledContent>
<Tooltip
title={
archivedAt
? formatDateYMDHM(
parseISO(archivedAt as string),
locationSettings.locale,
)
: undefined
}
arrow
placement='top'
>
<Box
sx={(theme) => ({
color: theme.palette.text.secondary,
})}
<StyledProjectCardHeader>
<StyledProjectCardTitleContainer data-loading>
<Truncator
title={name}
arrow
component={StyledProjectCardTitle}
>
<p data-loading>
Archived:{' '}
<TimeAgo date={archivedAt} refresh={false} />
</p>
</Box>
</Tooltip>
<Link
component={RouterLink}
to={`/archive?search=project%3A${encodeURI(id)}`}
>
<p>View archived flags</p>
</Link>
</StyledContent>
<Highlighter search={searchQuery}>
{name}
</Highlighter>
</Truncator>
</StyledProjectCardTitleContainer>
<ProjectModeBadge mode={mode} />
</StyledProjectCardHeader>
<StyledProjectCardContent>
{archivedAt && (
<div data-loading>
Archived{' '}
<Tooltip
title={formatDateYMDHM(
parseISO(archivedAt as string),
locationSettings.locale,
)}
arrow
>
<strong>
<TimeAgo
date={archivedAt}
refresh={false}
/>
</strong>
</Tooltip>
</div>
)}
<div data-loading>
<Link to={`/archive?search=project%3A${encodeURI(id)}`}>
View archived flags
</Link>
</div>
</StyledProjectCardContent>
</StyledProjectCardBody>
<ProjectCardFooter id={id} disabled owners={owners}>
<ProjectCardFooter id={id} owners={owners}>
<StyledActions>
<PermissionIconButton
onClick={onRevive}
@ -121,6 +106,7 @@ export const ProjectArchiveCard: FC<ProjectArchiveCardProps> = ({
permission={UPDATE_PROJECT}
tooltipProps={{ title: 'Revive project' }}
data-testid={`revive-feature-flag-button`}
size='small'
>
<Undo />
</PermissionIconButton>
@ -129,6 +115,7 @@ export const ProjectArchiveCard: FC<ProjectArchiveCardProps> = ({
projectId={id}
tooltipProps={{ title: 'Permanently delete project' }}
onClick={onDelete}
size='small'
>
<Delete />
</PermissionIconButton>

View File

@ -1,8 +1,6 @@
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';
import { flexColumn, flexRow } from 'themes/themeStyles';
export const StyledProjectCard = styled(Card)<{ disabled?: boolean }>(
({ theme, disabled = false }) => ({
@ -16,11 +14,11 @@ export const StyledProjectCard = styled(Card)<{ disabled?: boolean }>(
justifyContent: 'center',
},
transition: 'background-color 0.2s ease-in-out',
backgroundColor: disabled
? theme.palette.neutral.light
: theme.palette.background.default,
backgroundColor: theme.palette.background.default,
'&:hover': {
backgroundColor: theme.palette.neutral.light,
backgroundColor: disabled
? theme.palette.background.default
: theme.palette.action.hover,
},
borderRadius: theme.shape.borderRadiusMedium,
}),
@ -33,63 +31,31 @@ export const StyledProjectCardBody = styled(Box)(({ theme }) => ({
justifyContent: 'space-between',
height: '100%',
position: 'relative',
}));
export const StyledDivHeader = styled('div')(({ theme }) => ({
...flexRow,
width: '100%',
marginBottom: theme.spacing(2),
gap: theme.spacing(1),
}));
export const StyledCardTitle = styled('h3')<{ lines?: number }>(
({ theme, lines = 2 }) => ({
fontWeight: theme.typography.fontWeightRegular,
fontSize: theme.typography.body1.fontSize,
lineClamp: `${lines}`,
WebkitLineClamp: lines,
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 }) => ({
export const StyledProjectCardHeader = styled('div')(({ theme }) => ({
gap: theme.spacing(1),
display: 'flex',
width: '100%',
alignItems: 'center',
}));
export const StyledProjectCardTitleContainer = styled('div')(({ theme }) => ({
...flexColumn,
margin: theme.spacing(1, 'auto', 1, 0),
}));
export const StyledProjectCardTitle = styled('h3')(({ theme }) => ({
margin: 0,
marginRight: 'auto',
fontWeight: theme.typography.fontWeightRegular,
fontSize: theme.typography.body1.fontSize,
lineHeight: '1.2',
}));
export const StyledProjectCardContent = styled('div')(({ theme }) => ({
...flexRow,
justifyContent: 'space-between',
fontSize: theme.fontSizes.smallerBody,
padding: theme.spacing(0, 1),
}));
export const StyledParagraphInfo = styled('p')<{ disabled?: boolean }>(
({ theme, disabled = false }) => ({
color: disabled ? 'inherit' : theme.palette.primary.dark,
fontWeight: disabled ? 'normal' : 'bold',
fontSize: theme.typography.body1.fontSize,
}),
);
export const StyledActions = styled(Box)(({ theme }) => ({
display: 'flex',
margin: theme.spacing(0.5),
}));

View File

@ -1,13 +1,15 @@
import {
StyledProjectCardTitle,
StyledProjectCard,
StyledCardTitle,
StyledProjectCardBody,
StyledProjectCardHeader,
StyledProjectCardContent,
StyledProjectCardTitleContainer,
} from './ProjectCard.styles';
import { ProjectCardFooter } from './ProjectCardFooter/ProjectCardFooter.tsx';
import { ProjectModeBadge } from './ProjectModeBadge/ProjectModeBadge.tsx';
import { FavoriteAction } from './FavoriteAction/FavoriteAction.tsx';
import { Box, styled } from '@mui/material';
import { flexColumn, flexRow } from 'themes/themeStyles';
import { styled } from '@mui/material';
import { TimeAgo } from 'component/common/TimeAgo/TimeAgo';
import { ProjectLastSeen } from './ProjectLastSeen/ProjectLastSeen.tsx';
import { Highlighter } from 'component/common/Highlighter/Highlighter';
@ -16,41 +18,18 @@ import { ProjectMembers } from './ProjectCardFooter/ProjectMembers/ProjectMember
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { DEFAULT_PROJECT_ID } from 'hooks/api/getters/useDefaultProject/useDefaultProjectId';
import type { ProjectSchema } from 'openapi';
import { Truncator } from 'component/common/Truncator/Truncator.tsx';
const StyledUpdated = styled('span')(({ theme }) => ({
const StyledSubtitle = styled('span')(({ theme }) => ({
color: theme.palette.text.secondary,
fontSize: theme.fontSizes.smallerBody,
}));
const StyledCount = styled('strong')(({ theme }) => ({
fontWeight: theme.typography.fontWeightMedium,
}));
const StyledInfo = styled('div')(({ theme }) => ({
...flexColumn,
fontSize: theme.fontSizes.smallerBody,
}));
const StyledContent = styled('div')({
...flexRow,
justifyContent: 'space-between',
});
const StyledHeader = styled('div')(({ theme }) => ({
gap: theme.spacing(1),
display: 'flex',
width: '100%',
alignItems: 'center',
}));
type ProjectCardProps = ProjectSchema & { onHover?: () => void };
export const ProjectCard = ({
name,
featureCount,
health,
memberCount = 0,
onHover,
id,
mode,
favorite = false,
@ -58,47 +37,47 @@ export const ProjectCard = ({
createdAt,
lastUpdatedAt,
lastReportedFlagUsage,
}: ProjectCardProps) => {
}: ProjectSchema) => {
const { searchQuery } = useSearchHighlightContext();
return (
<StyledProjectCard onMouseEnter={onHover}>
<StyledProjectCard>
<StyledProjectCardBody>
<StyledHeader>
<Box
data-loading
sx={(theme) => ({
...flexColumn,
margin: theme.spacing(1, 'auto', 1, 0),
})}
>
<StyledCardTitle lines={1} sx={{ margin: 0 }}>
<StyledProjectCardHeader>
<StyledProjectCardTitleContainer data-loading>
<Truncator
title={name}
arrow
component={StyledProjectCardTitle}
>
<Highlighter search={searchQuery}>
{name}
</Highlighter>
</StyledCardTitle>
<StyledUpdated>
</Truncator>
<StyledSubtitle>
Updated{' '}
<TimeAgo date={lastUpdatedAt || createdAt} />
</StyledUpdated>
</Box>
</StyledSubtitle>
</StyledProjectCardTitleContainer>
<ProjectModeBadge mode={mode} />
<FavoriteAction id={id} isFavorite={favorite} />
</StyledHeader>
<StyledInfo>
<div data-loading>
<StyledCount>{featureCount}</StyledCount> flag
{featureCount === 1 ? '' : 's'}
</div>
<StyledContent>
</StyledProjectCardHeader>
<div>
<StyledProjectCardContent>
<div data-loading>
<StyledCount>{health}%</StyledCount> health
<strong>{featureCount}</strong> flag
{featureCount === 1 ? '' : 's'}
</div>
</StyledProjectCardContent>
<StyledProjectCardContent>
<div data-loading>
<strong>{health}%</strong> health
</div>
<div data-loading>
<ProjectLastSeen date={lastReportedFlagUsage} />
</div>
</StyledContent>
</StyledInfo>
</StyledProjectCardContent>
</div>
</StyledProjectCardBody>
<ProjectCardFooter id={id} owners={owners}>
<ConditionallyRender

View File

@ -11,35 +11,29 @@ interface IProjectCardFooterProps {
id?: string;
isFavorite?: boolean;
children?: React.ReactNode;
disabled?: boolean;
owners?: IProjectOwnersProps['owners'];
}
const StyledFooter = styled(Box)<{ disabled: boolean }>(
({ theme, disabled }) => ({
display: 'flex',
background: disabled
? theme.palette.background.paper
: theme.palette.background.elevation1,
boxShadow: theme.boxShadows.accordionFooter,
alignItems: 'center',
justifyContent: 'space-between',
borderTop: `1px solid ${theme.palette.divider}`,
paddingInline: theme.spacing(2),
paddingBlock: theme.spacing(1.5),
}),
);
const StyledFooter = styled(Box)(({ theme }) => ({
display: 'flex',
background: theme.palette.background.elevation1,
boxShadow: theme.boxShadows.accordionFooter,
alignItems: 'center',
justifyContent: 'space-between',
borderTop: `1px solid ${theme.palette.divider}`,
paddingInline: theme.spacing(2),
paddingBlock: theme.spacing(1.5),
}));
export const ProjectCardFooter: FC<IProjectCardFooterProps> = ({
children,
owners,
disabled = false,
}) => {
const ownersWithoutSystem = owners?.filter(
(owner) => owner.ownerType !== 'system',
);
return (
<StyledFooter disabled={disabled}>
<StyledFooter>
{ownersWithoutSystem ? (
<ProjectOwners
owners={ownersWithoutSystem as ProjectSchemaOwners}

View File

@ -4,9 +4,7 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
import { ProjectCard as DefaultProjectCard } from '../ProjectCard/ProjectCard.tsx';
import type { ProjectSchema } from 'openapi';
import loadingData from './loadingData.ts';
import { TablePlaceholder } from 'component/common/Table';
import { styled } from '@mui/material';
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { UpgradeProjectCard } from '../ProjectCard/UpgradeProjectCard.tsx';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
@ -40,86 +38,57 @@ type ProjectGroupProps = {
export const ProjectGroup = ({
projects,
loading,
placeholder = 'No projects available.',
ProjectCardComponent,
link = true,
}: ProjectGroupProps) => {
const ProjectCard = ProjectCardComponent ?? DefaultProjectCard;
const { isOss } = useUiConfig();
const { searchQuery } = useSearchHighlightContext();
return (
<>
<StyledGridContainer>
<ConditionallyRender
condition={projects.length < 1 && !loading}
show={
<ConditionallyRender
condition={searchQuery?.length > 0}
show={
<TablePlaceholder>
No projects found matching &ldquo;
{searchQuery}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>{placeholder}</TablePlaceholder>
}
/>
}
elseShow={
<StyledGridContainer>
<ConditionallyRender
condition={loading}
show={() => (
<>
{loadingData.map(
(project: ProjectSchema) => (
<ProjectCard
data-loading
createdAt={project.createdAt}
key={project.id}
name={project.name}
id={project.id}
mode={project.mode}
memberCount={2}
health={95}
featureCount={4}
owners={[
{
ownerType: 'user',
name: 'Loading data',
},
]}
/>
),
)}
</>
)}
elseShow={() => (
<>
{projects.map((project) =>
link ? (
<StyledCardLink
key={project.id}
to={`/projects/${project.id}`}
>
<ProjectCard {...project} />
</StyledCardLink>
) : (
<ProjectCard
key={project.id}
{...project}
/>
),
)}
</>
)}
/>
{isOss() ? <UpgradeProjectCard /> : null}
</StyledGridContainer>
}
condition={loading}
show={() => (
<>
{loadingData.map((project: ProjectSchema) => (
<ProjectCard
data-loading
createdAt={project.createdAt}
key={project.id}
name={project.name}
id={project.id}
mode={project.mode}
memberCount={2}
health={95}
featureCount={4}
owners={[
{
ownerType: 'user',
name: 'Loading data',
},
]}
/>
))}
</>
)}
elseShow={() => (
<>
{projects.map((project) =>
link ? (
<StyledCardLink
key={project.id}
to={`/projects/${project.id}`}
>
<ProjectCard {...project} />
</StyledCardLink>
) : (
<ProjectCard key={project.id} {...project} />
),
)}
</>
)}
/>
</>
{isOss() ? <UpgradeProjectCard /> : null}
</StyledGridContainer>
);
};

View File

@ -18,6 +18,7 @@ import { useProjectsSearchAndSort } from './hooks/useProjectsSearchAndSort.ts';
import { ProjectArchiveLink } from './ProjectArchiveLink/ProjectArchiveLink.tsx';
import { ProjectsListHeader } from './ProjectsListHeader/ProjectsListHeader.tsx';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { TablePlaceholder } from 'component/common/Table/index.ts';
const StyledApiError = styled(ApiError)(({ theme }) => ({
maxWidth: '500px',
@ -38,7 +39,7 @@ export const ProjectList = () => {
const [state, setState] = useProjectsListState();
const myProjects = new Set(useProfile().profile?.projects || []);
const myProfileProjects = new Set(useProfile().profile?.projects || []);
const setSearchValue = useCallback(
(value: string) => setState({ query: value || undefined }),
@ -50,13 +51,20 @@ export const ProjectList = () => {
state.query,
state.sortBy,
);
const groupedProjects = useGroupedProjects(sortedProjects, myProjects);
const groupedProjects = useGroupedProjects(
sortedProjects,
myProfileProjects,
);
const projectCount =
sortedProjects.length < projects.length
? `${sortedProjects.length} of ${projects.length}`
: projects.length;
const myProjects = isOss() ? sortedProjects : groupedProjects.myProjects;
const otherProjects = isOss() ? [] : groupedProjects.otherProjects;
return (
<PageContent
isLoading={loading}
@ -66,7 +74,7 @@ export const ProjectList = () => {
actions={
<>
<ConditionallyRender
condition={!isOss && !isSmallScreen}
condition={!isOss() && !isSmallScreen}
show={
<>
<Search
@ -113,42 +121,61 @@ export const ProjectList = () => {
)}
/>
<SearchHighlightProvider value={state.query || ''}>
<div>
<ProjectsListHeader
subtitle='Favorite projects, projects you own, and projects you are a member of'
actions={
<ProjectsListSort
sortBy={state.sortBy}
setSortBy={(sortBy) =>
setState({
sortBy: sortBy as typeof state.sortBy,
})
}
/>
}
>
My projects
</ProjectsListHeader>
<ProjectGroup
loading={loading}
projects={
isOss()
? sortedProjects
: groupedProjects.myProjects
}
/>
</div>
{!isOss() ? (
{myProjects.length > 0 && (
<div>
<ProjectsListHeader subtitle='Projects in Unleash that you have access to.'>
<ProjectsListHeader
helpText='Favorite projects, projects you own, and projects you are a member of'
actions={
<ProjectsListSort
sortBy={state.sortBy}
setSortBy={(sortBy) =>
setState({
sortBy: sortBy as typeof state.sortBy,
})
}
/>
}
>
My projects
</ProjectsListHeader>
<ProjectGroup
loading={loading}
projects={
isOss()
? sortedProjects
: groupedProjects.myProjects
}
/>
</div>
)}
{otherProjects.length > 0 && (
<div>
<ProjectsListHeader helpText='Projects in Unleash that you have access to.'>
Other projects
</ProjectsListHeader>
<ProjectGroup
loading={loading}
projects={groupedProjects.otherProjects}
projects={otherProjects}
/>
</div>
) : null}
)}
{!loading &&
!myProjects.length &&
!otherProjects.length && (
<>
{state.query?.length ? (
<TablePlaceholder>
No projects found matching &ldquo;
{state.query}
&rdquo;
</TablePlaceholder>
) : (
<TablePlaceholder>
No projects available.
</TablePlaceholder>
)}
</>
)}
</SearchHighlightProvider>
</StyledContainer>
</PageContent>

View File

@ -1,9 +1,10 @@
import { styled, Typography } from '@mui/material';
import { styled } from '@mui/material';
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
import type { FC, ReactNode } from 'react';
type ProjectsListHeaderProps = {
children?: ReactNode;
subtitle?: string;
children: ReactNode;
helpText: string;
actions?: ReactNode;
};
@ -18,28 +19,22 @@ const StyledHeaderContainer = styled('div')(({ theme }) => ({
marginBottom: theme.spacing(2),
}));
const StyledHeaderTitle = styled('div')(() => ({
const StyledHeaderTitle = styled('div')(({ theme }) => ({
display: 'flex',
gap: theme.spacing(1),
flexGrow: 0,
}));
export const ProjectsListHeader: FC<ProjectsListHeaderProps> = ({
children,
subtitle,
helpText,
actions,
}) => {
return (
<StyledHeaderContainer>
<StyledHeaderTitle>
{children ? (
<Typography component='h2' variant='h2'>
{children}
</Typography>
) : null}
{subtitle ? (
<Typography variant='body2' color='text.secondary'>
{subtitle}
</Typography>
) : null}
{children}
<HelpIcon tooltip={helpText} />
</StyledHeaderTitle>
{actions}
</StyledHeaderContainer>

View File

@ -9,7 +9,7 @@ const StyledWrapper = styled('div')(({ theme }) => ({
}));
const StyledContainer = styled('div')(() => ({
maxWidth: '220px',
maxWidth: '200px',
width: '100%',
}));