1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-18 00:19:49 +01:00

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

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);