1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-06 01:15:28 +02:00

feat: split projects view into "my projects" and "other projects" (#6886)

This PR removes the previous "my projects" filter in favor always
splitting projects, but showing both on the main screen.

To make it a bit easier to work with, it also moves the project group
component into its own file, causing some extra lines of code change. My
apologies 🙇🏼
This commit is contained in:
Thomas Heartman 2024-04-22 13:16:53 +02:00 committed by GitHub
parent b8380a0b5b
commit b6833d92aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 207 additions and 204 deletions

View File

@ -57,15 +57,6 @@ const StyledHeaderActions = styled('div')(({ theme }) => ({
gap: theme.spacing(1),
}));
const StyledLeftHeaderActions = styled('div')(({ theme }) => ({
display: 'flex',
flexGrow: 1,
justifyContent: 'flex-start',
alignItems: 'center',
gap: theme.spacing(1),
marginRight: theme.spacing(2),
}));
interface IPageHeaderProps {
title?: string;
titleElement?: ReactNode;
@ -73,7 +64,6 @@ interface IPageHeaderProps {
variant?: TypographyProps['variant'];
loading?: boolean;
actions?: ReactNode;
leftActions?: ReactNode;
className?: string;
secondary?: boolean;
}
@ -84,7 +74,6 @@ const PageHeaderComponent: FC<IPageHeaderProps> & {
title,
titleElement,
actions,
leftActions,
subtitle,
variant,
loading,
@ -111,14 +100,6 @@ const PageHeaderComponent: FC<IPageHeaderProps> & {
</StyledHeaderTitle>
{subtitle && <small>{subtitle}</small>}
</StyledHeader>
<ConditionallyRender
condition={Boolean(leftActions)}
show={
<StyledLeftHeaderActions>
{leftActions}
</StyledLeftHeaderActions>
}
/>
<ConditionallyRender
condition={Boolean(actions)}
show={<StyledHeaderActions>{actions}</StyledHeaderActions>}

View File

@ -0,0 +1,138 @@
import { Link } from 'react-router-dom';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
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 { TablePlaceholder } from 'component/common/Table';
import { styled, Typography } from '@mui/material';
import { useUiFlag } from 'hooks/useUiFlag';
const StyledProjectGroupContainer = styled('article')(({ theme }) => ({
h3: {
marginBlockEnd: theme.spacing(2),
},
'&+&': {
marginBlockStart: theme.spacing(4),
},
}));
/**
* @deprecated Remove after with `projectsListNewCards` flag
*/
const StyledDivContainer = styled('div')(({ theme }) => ({
display: 'flex',
flexWrap: 'wrap',
[theme.breakpoints.down('sm')]: {
justifyContent: 'center',
},
}));
const StyledGridContainer = styled('div')(({ theme }) => ({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
gap: theme.spacing(2),
}));
const StyledCardLink = styled(Link)(({ theme }) => ({
color: 'inherit',
textDecoration: 'none',
border: 'none',
padding: '0',
background: 'transparent',
fontFamily: theme.typography.fontFamily,
pointer: 'cursor',
}));
export const ProjectGroup: React.FC<{
sectionTitle?: string;
projects: IProjectCard[];
loading: boolean;
searchValue: string;
handleHover: (id: string) => void;
}> = ({ sectionTitle, projects, loading, searchValue, handleHover }) => {
const useNewProjectCards = useUiFlag('projectsListNewCards');
const [StyledItemsContainer, ProjectCard] = useNewProjectCards
? [StyledGridContainer, NewProjectCard]
: [StyledDivContainer, LegacyProjectCard];
return (
<StyledProjectGroupContainer>
<ConditionallyRender
condition={Boolean(sectionTitle)}
show={<Typography component='h3'>{sectionTitle}</Typography>}
/>
<ConditionallyRender
condition={projects.length < 1 && !loading}
show={
<ConditionallyRender
condition={searchValue?.length > 0}
show={
<TablePlaceholder>
No projects found matching &ldquo;
{searchValue}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No projects available.
</TablePlaceholder>
}
/>
}
elseShow={
<StyledItemsContainer>
<ConditionallyRender
condition={loading}
show={() =>
loadingData.map((project: IProjectCard) => (
<ProjectCard
data-loading
onHover={() => {}}
key={project.id}
name={project.name}
id={project.id}
mode={project.mode}
memberCount={2}
health={95}
featureCount={4}
/>
))
}
elseShow={() => (
<>
{projects.map((project: IProjectCard) => (
<StyledCardLink
key={project.id}
to={`/projects/${project.id}`}
>
<ProjectCard
onHover={() =>
handleHover(project.id)
}
name={project.name}
mode={project.mode}
memberCount={
project.memberCount ?? 0
}
health={project.health}
id={project.id}
featureCount={
project.featureCount
}
isFavorite={project.favorite}
/>
</StyledCardLink>
))}
</>
)}
/>
</StyledItemsContainer>
}
/>
</StyledProjectGroupContainer>
);
};

View File

@ -1,13 +1,10 @@
import { useContext, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
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 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';
import AccessContext from 'contexts/AccessContext';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
@ -16,13 +13,7 @@ import { CREATE_PROJECT } from 'component/providers/AccessProvider/permissions';
import Add from '@mui/icons-material/Add';
import ApiError from 'component/common/ApiError/ApiError';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { TablePlaceholder } from 'component/common/Table';
import {
useMediaQuery,
styled,
ToggleButtonGroup,
ToggleButton,
} from '@mui/material';
import { useMediaQuery, styled } from '@mui/material';
import theme from 'themes/theme';
import { Search } from 'component/common/Search/Search';
import { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature';
@ -33,58 +24,14 @@ import { safeRegExp } from '@server/util/escape-regex';
import { ThemeMode } from 'component/common/ThemeMode/ThemeMode';
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',
[theme.breakpoints.down('sm')]: {
justifyContent: 'center',
},
}));
const StyledGridContainer = styled('div')(({ theme }) => ({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
gap: theme.spacing(2),
}));
import { groupProjects } from './group-projects';
import { ProjectGroup } from './ProjectGroup';
const StyledApiError = styled(ApiError)(({ theme }) => ({
maxWidth: '400px',
marginBottom: theme.spacing(2),
}));
const StyledCardLink = styled(Link)(({ theme }) => ({
color: 'inherit',
textDecoration: 'none',
border: 'none',
padding: '0',
background: 'transparent',
fontFamily: theme.typography.fontFamily,
pointer: 'cursor',
}));
const StyledButtonGroup = styled(ToggleButtonGroup)(({ theme }) => ({
button: {
color: theme.palette.primary.main,
borderColor: theme.palette.background.alternative,
textTransform: 'none',
paddingInline: theme.spacing(3),
transition: 'background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms',
},
'button[aria-pressed=true]': {
backgroundColor: theme.palette.background.alternative,
color: theme.palette.primary.contrastText,
'&:hover': {
backgroundColor: theme.palette.action.alternative,
},
},
}));
type PageQueryType = Partial<Record<'search', string>>;
type projectMap = {
@ -148,10 +95,7 @@ export const ProjectListNew = () => {
searchParams.get('search') || '',
);
const showProjectFilterButtons = useUiFlag('projectListFilterMyProjects');
const projectsListNewCards = useUiFlag('projectsListNewCards');
const filters = ['All projects', 'My projects'];
const [filter, setFilter] = useState(filters[0]);
const splitProjectList = useUiFlag('projectListFilterMyProjects');
const myProjects = new Set(useProfile().profile?.projects || []);
useEffect(() => {
@ -166,18 +110,11 @@ export const ProjectListNew = () => {
}, [searchValue, setSearchParams]);
const filteredProjects = useMemo(() => {
const preFilteredProjects =
showProjectFilterButtons && filter === 'My projects'
? projects.filter(shouldDisplayInMyProjects(myProjects))
: projects;
const regExp = safeRegExp(searchValue, 'i');
return (
searchValue
? preFilteredProjects.filter((project) =>
regExp.test(project.name),
)
: preFilteredProjects
? projects.filter((project) => regExp.test(project.name))
: projects
).sort((a, b) => {
if (a?.favorite && !b?.favorite) {
return -1;
@ -187,7 +124,14 @@ export const ProjectListNew = () => {
}
return 0;
});
}, [projects, searchValue, filter, myProjects, showProjectFilterButtons]);
}, [projects, searchValue]);
const groupedProjects = useMemo(() => {
if (!splitProjectList) {
return { myProjects: [], otherProjects: filteredProjects };
}
return groupProjects(myProjects, filteredProjects);
}, [filteredProjects, myProjects, splitProjectList]);
const handleHover = (projectId: string) => {
if (fetchedProjects[projectId]) {
@ -215,49 +159,25 @@ export const ProjectListNew = () => {
? `${filteredProjects.length} of ${projects.length}`
: projects.length;
const StyledItemsContainer = projectsListNewCards
? StyledGridContainer
: StyledDivContainer;
const ProjectCard = projectsListNewCards
? NewProjectCard
: LegacyProjectCard;
const ProjectGroupComponent = (props: {
sectionTitle?: string;
projects: IProjectCard[];
}) => {
return (
<ProjectGroup
loading={loading}
searchValue={searchValue}
handleHover={handleHover}
{...props}
/>
);
};
return (
<PageContent
isLoading={loading}
header={
<PageHeader
title={`Projects (${projectCount})`}
leftActions={
<ConditionallyRender
condition={showProjectFilterButtons}
show={
<StyledButtonGroup
aria-label='project list filter'
size='small'
color='primary'
value={filter}
exclusive
onChange={(event, value) => {
if (value !== null) {
setFilter(value);
}
}}
>
{filters.map((filter) => {
return (
<ToggleButton
key={filter}
value={filter}
>
{filter}
</ToggleButton>
);
})}
</StyledButtonGroup>
}
/>
}
actions={
<>
<ConditionallyRender
@ -300,75 +220,23 @@ export const ProjectListNew = () => {
}
>
<ConditionallyRender condition={error} show={renderError()} />
<StyledItemsContainer>
<ConditionallyRender
condition={filteredProjects.length < 1 && !loading}
show={
<ConditionallyRender
condition={searchValue?.length > 0}
show={
<TablePlaceholder>
No projects found matching &ldquo;
{searchValue}
&rdquo;
</TablePlaceholder>
}
elseShow={
<TablePlaceholder>
No projects available.
</TablePlaceholder>
}
<ConditionallyRender
condition={splitProjectList}
show={
<>
<ProjectGroupComponent
sectionTitle='My projects'
projects={groupedProjects.myProjects}
/>
}
elseShow={
<ConditionallyRender
condition={loading}
show={() =>
loadingData.map((project: IProjectCard) => (
<ProjectCard
data-loading
onHover={() => {}}
key={project.id}
name={project.name}
id={project.id}
mode={project.mode}
memberCount={2}
health={95}
featureCount={4}
/>
))
}
elseShow={() =>
filteredProjects.map(
(project: IProjectCard) => (
<StyledCardLink
key={project.id}
to={`/projects/${project.id}`}
>
<ProjectCard
onHover={() =>
handleHover(project.id)
}
name={project.name}
mode={project.mode}
memberCount={
project.memberCount ?? 0
}
health={project.health}
id={project.id}
featureCount={
project.featureCount
}
isFavorite={project.favorite}
/>
</StyledCardLink>
),
)
}
<ProjectGroupComponent
sectionTitle='Other projects'
projects={groupedProjects.otherProjects}
/>
}
/>
</StyledItemsContainer>
</>
}
elseShow={<ProjectGroupComponent projects={filteredProjects} />}
/>
</PageContent>
);
};

View File

@ -1,8 +1,8 @@
import type { IProjectCard } from 'interfaces/project';
import { shouldDisplayInMyProjects } from './should-display-in-my-projects';
import { groupProjects } from './group-projects';
test('should check that the project is a user project OR that it is a favorite', () => {
const myProjects = new Set(['my1', 'my2', 'my3']);
const myProjectIds = new Set(['my1', 'my2', 'my3']);
const projects: IProjectCard[] = [
{ id: 'my1', favorite: true },
@ -23,12 +23,16 @@ test('should check that the project is a user project OR that it is a favorite',
favorite,
}));
const filtered = projects.filter(shouldDisplayInMyProjects(myProjects));
const { myProjects, otherProjects } = groupProjects(myProjectIds, projects);
expect(filtered).toMatchObject([
expect(myProjects).toMatchObject([
{ id: 'my1' },
{ id: 'my2' },
{ id: 'my3' },
{ id: 'fave-but-not-mine' },
]);
expect(otherProjects).toMatchObject([
{ id: 'not-mine-not-fave' },
{ id: 'not-mine-undefined-fave' },
]);
});

View File

@ -0,0 +1,18 @@
import type { IProjectCard } from 'interfaces/project';
export const groupProjects = (
myProjectIds: Set<string>,
filteredProjects: IProjectCard[],
) => {
const mine: IProjectCard[] = [];
const other: IProjectCard[] = [];
for (const project of filteredProjects) {
if (project.favorite || myProjectIds.has(project.id)) {
mine.push(project);
} else {
other.push(project);
}
}
return { myProjects: mine, otherProjects: other };
};

View File

@ -1,6 +0,0 @@
import type { IProjectCard } from 'interfaces/project';
export const shouldDisplayInMyProjects =
(myProjectIds: Set<string>) =>
(project: IProjectCard): boolean =>
project.favorite || myProjectIds.has(project.id);