1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

feat: projects list sorting (#8011)

Ability to sort projects.
This commit is contained in:
Tymoteusz Czech 2024-08-29 15:43:16 +02:00 committed by GitHub
parent c5d6bdecac
commit 8b68a0657f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 849 additions and 213 deletions

View File

@ -8,7 +8,7 @@ import { EEA, P } from 'component/common/flags';
import { NewUser } from 'component/user/NewUser/NewUser';
import ResetPassword from 'component/user/ResetPassword/ResetPassword';
import ForgottenPassword from 'component/user/ForgottenPassword/ForgottenPassword';
import { ProjectListNew } from 'component/project/ProjectList/ProjectList';
import { ProjectList } from 'component/project/ProjectList/ProjectList';
import { ArchiveProjectList } from 'component/project/ProjectList/ArchiveProjectList';
import RedirectArchive from 'component/archive/RedirectArchive';
import CreateEnvironment from 'component/environments/CreateEnvironment/CreateEnvironment';
@ -113,7 +113,7 @@ export const routes: IRoute[] = [
{
path: '/projects',
title: 'Projects',
component: ProjectListNew,
component: ProjectList,
type: 'protected',
menu: { mobile: true },
},

View File

@ -15,6 +15,8 @@ import { flexColumn } from 'themes/themeStyles';
import { TimeAgo } from 'component/common/TimeAgo/TimeAgo';
import { ProjectLastSeen } from './ProjectLastSeen/ProjectLastSeen';
import type { IProjectCard } from 'interfaces/project';
import { Highlighter } from 'component/common/Highlighter/Highlighter';
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
const StyledUpdated = styled('span')(({ theme }) => ({
color: theme.palette.text.secondary,
@ -45,48 +47,58 @@ export const ProjectCard = ({
owners,
lastUpdatedAt,
lastReportedFlagUsage,
}: IProjectCard) => (
<StyledProjectCard onMouseEnter={onHover}>
<StyledProjectCardBody>
<StyledDivHeader>
<StyledIconBox>
<ProjectIcon />
</StyledIconBox>
<Box
data-loading
sx={(theme) => ({
...flexColumn,
margin: theme.spacing(1, 'auto', 1, 0),
})}
>
<StyledCardTitle lines={1} sx={{ margin: 0 }}>
{name}
</StyledCardTitle>
<ConditionallyRender
condition={Boolean(lastUpdatedAt)}
show={
<StyledUpdated>
Updated <TimeAgo date={lastUpdatedAt} />
</StyledUpdated>
}
/>
</Box>
<ProjectModeBadge mode={mode} />
<FavoriteAction id={id} isFavorite={favorite} />
</StyledDivHeader>
<StyledInfo>
<div>
}: IProjectCard) => {
const { searchQuery } = useSearchHighlightContext();
return (
<StyledProjectCard onMouseEnter={onHover}>
<StyledProjectCardBody>
<StyledDivHeader>
<StyledIconBox>
<ProjectIcon />
</StyledIconBox>
<Box
data-loading
sx={(theme) => ({
...flexColumn,
margin: theme.spacing(1, 'auto', 1, 0),
})}
>
<StyledCardTitle lines={1} sx={{ margin: 0 }}>
<Highlighter search={searchQuery}>
{name}
</Highlighter>
</StyledCardTitle>
<ConditionallyRender
condition={Boolean(lastUpdatedAt)}
show={
<StyledUpdated>
Updated <TimeAgo date={lastUpdatedAt} />
</StyledUpdated>
}
/>
</Box>
<ProjectModeBadge mode={mode} />
<FavoriteAction id={id} isFavorite={favorite} />
</StyledDivHeader>
<StyledInfo>
<div>
<StyledCount>{featureCount}</StyledCount> flag
{featureCount === 1 ? '' : 's'}
<div>
<StyledCount>{featureCount}</StyledCount> flag
{featureCount === 1 ? '' : 's'}
</div>
<div>
<StyledCount>{health}%</StyledCount> health
</div>
</div>
<div>
<StyledCount>{health}%</StyledCount> health
</div>
</div>
<ProjectLastSeen date={lastReportedFlagUsage} />
</StyledInfo>
</StyledProjectCardBody>
<ProjectCardFooter id={id} owners={owners} memberCount={memberCount} />
</StyledProjectCard>
);
<ProjectLastSeen date={lastReportedFlagUsage} />
</StyledInfo>
</StyledProjectCardBody>
<ProjectCardFooter
id={id}
owners={owners}
memberCount={memberCount}
/>
</StyledProjectCard>
);
};

View File

@ -0,0 +1,256 @@
import { type FC, useContext, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import useProjects from 'hooks/api/getters/useProjects/useProjects';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import type { IProjectCard } from 'interfaces/project';
import { PageContent } from 'component/common/PageContent/PageContent';
import AccessContext from 'contexts/AccessContext';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
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 { Link, styled, useMediaQuery } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
import theme from 'themes/theme';
import { Search } from 'component/common/Search/Search';
import { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature';
import type { ITooltipResolverProps } from 'component/common/TooltipResolver/TooltipResolver';
import { ReactComponent as ProPlanIcon } from 'assets/icons/pro-enterprise-feature-badge.svg';
import { ReactComponent as ProPlanIconLight } from 'assets/icons/pro-enterprise-feature-badge-light.svg';
import { safeRegExp } from '@server/util/escape-regex';
import { ThemeMode } from 'component/common/ThemeMode/ThemeMode';
import { useProfile } from 'hooks/api/getters/useProfile/useProfile';
import { groupProjects } from './group-projects';
import { ProjectGroup } from './ProjectGroup';
import { CreateProjectDialog } from '../Project/CreateProject/NewCreateProjectForm/CreateProjectDialog';
import { useUiFlag } from 'hooks/useUiFlag';
const StyledApiError = styled(ApiError)(({ theme }) => ({
maxWidth: '500px',
marginBottom: theme.spacing(2),
}));
const StyledContainer = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(4),
}));
type PageQueryType = Partial<Record<'search', string>>;
interface ICreateButtonData {
disabled: boolean;
tooltip?: Omit<ITooltipResolverProps, 'children'>;
endIcon?: React.ReactNode;
}
const NAVIGATE_TO_CREATE_PROJECT = 'NAVIGATE_TO_CREATE_PROJECT';
function resolveCreateButtonData(
isOss: boolean,
hasAccess: boolean,
): ICreateButtonData {
if (isOss) {
return {
disabled: true,
tooltip: {
titleComponent: (
<PremiumFeature feature='adding-new-projects' tooltip />
),
sx: { maxWidth: '320px' },
variant: 'custom',
},
endIcon: (
<ThemeMode
darkmode={<ProPlanIconLight />}
lightmode={<ProPlanIcon />}
/>
),
};
} else if (!hasAccess) {
return {
tooltip: {
title: 'You do not have permission to create new projects',
},
disabled: true,
};
} else {
return {
tooltip: { title: 'Click to create a new project' },
disabled: false,
};
}
}
const ProjectCreationButton: FC = () => {
const [searchParams] = useSearchParams();
const showCreateDialog = Boolean(searchParams.get('create'));
const [openCreateDialog, setOpenCreateDialog] = useState(showCreateDialog);
const { hasAccess } = useContext(AccessContext);
const { isOss, loading } = useUiConfig();
const createButtonData = resolveCreateButtonData(
isOss(),
hasAccess(CREATE_PROJECT),
);
return (
<>
<ResponsiveButton
Icon={Add}
endIcon={createButtonData.endIcon}
onClick={() => setOpenCreateDialog(true)}
maxWidth='700px'
permission={CREATE_PROJECT}
disabled={createButtonData.disabled || loading}
tooltipProps={createButtonData.tooltip}
data-testid={NAVIGATE_TO_CREATE_PROJECT}
>
New project
</ResponsiveButton>
<CreateProjectDialog
open={openCreateDialog}
onClose={() => setOpenCreateDialog(false)}
/>
</>
);
};
export const ProjectList = () => {
const { projects, loading, error, refetch } = useProjects();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const [searchParams, setSearchParams] = useSearchParams();
const [searchValue, setSearchValue] = useState(
searchParams.get('search') || '',
);
const archiveProjectsEnabled = useUiFlag('archiveProjects');
const myProjects = new Set(useProfile().profile?.projects || []);
useEffect(() => {
const tableState: PageQueryType = {};
if (searchValue) {
tableState.search = searchValue;
}
setSearchParams(tableState, {
replace: true,
});
}, [searchValue, setSearchParams]);
const filteredProjects = useMemo(() => {
const regExp = safeRegExp(searchValue, 'i');
return (
searchValue
? projects.filter((project) => regExp.test(project.name))
: projects
).sort((a, b) => {
if (a?.favorite && !b?.favorite) {
return -1;
}
if (!a?.favorite && b?.favorite) {
return 1;
}
return 0;
});
}, [projects, searchValue]);
const groupedProjects = useMemo(() => {
return groupProjects(myProjects, filteredProjects);
}, [filteredProjects, myProjects]);
const projectCount =
filteredProjects.length < projects.length
? `${filteredProjects.length} of ${projects.length}`
: projects.length;
const ProjectGroupComponent = (props: {
sectionTitle?: string;
projects: IProjectCard[];
}) => {
return (
<ProjectGroup
loading={loading}
searchValue={searchValue}
{...props}
/>
);
};
return (
<PageContent
isLoading={loading}
header={
<PageHeader
title={`Projects (${projectCount})`}
actions={
<>
<ConditionallyRender
condition={!isSmallScreen}
show={
<>
<Search
initialValue={searchValue}
onChange={setSearchValue}
/>
<PageHeader.Divider />
</>
}
/>
<ConditionallyRender
condition={Boolean(archiveProjectsEnabled)}
show={
<>
<Link
component={RouterLink}
to='/projects-archive'
>
Archived projects
</Link>
<PageHeader.Divider />
</>
}
/>
<ProjectCreationButton />
</>
}
>
<ConditionallyRender
condition={isSmallScreen}
show={
<Search
initialValue={searchValue}
onChange={setSearchValue}
/>
}
/>
</PageHeader>
}
>
<StyledContainer>
<ConditionallyRender
condition={error}
show={() => (
<StyledApiError
onClick={refetch}
text='Error fetching projects'
/>
)}
/>
<ProjectGroupComponent
sectionTitle='My projects'
projects={groupedProjects.myProjects}
/>
<ProjectGroupComponent
sectionTitle='Other projects'
projects={groupedProjects.otherProjects}
/>
</StyledContainer>
</PageContent>
);
};

View File

@ -0,0 +1,95 @@
import { useContext, type FC, type ReactNode } from 'react';
import type { ITooltipResolverProps } from 'component/common/TooltipResolver/TooltipResolver';
import AccessContext from 'contexts/AccessContext';
import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
import { CREATE_PROJECT } from 'component/providers/AccessProvider/permissions';
import Add from '@mui/icons-material/Add';
import { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature';
import { ReactComponent as ProPlanIcon } from 'assets/icons/pro-enterprise-feature-badge.svg';
import { ReactComponent as ProPlanIconLight } from 'assets/icons/pro-enterprise-feature-badge-light.svg';
import { CreateProjectDialog } from '../../Project/CreateProject/NewCreateProjectForm/CreateProjectDialog';
import { ThemeMode } from 'component/common/ThemeMode/ThemeMode';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
interface ICreateButtonData {
disabled: boolean;
tooltip?: Omit<ITooltipResolverProps, 'children'>;
endIcon?: ReactNode;
}
const NAVIGATE_TO_CREATE_PROJECT = 'NAVIGATE_TO_CREATE_PROJECT';
function resolveCreateButtonData(
isOss: boolean,
hasAccess: boolean,
): ICreateButtonData {
if (isOss) {
return {
disabled: true,
tooltip: {
titleComponent: (
<PremiumFeature feature='adding-new-projects' tooltip />
),
sx: { maxWidth: '320px' },
variant: 'custom',
},
endIcon: (
<ThemeMode
darkmode={<ProPlanIconLight />}
lightmode={<ProPlanIcon />}
/>
),
};
} else if (!hasAccess) {
return {
tooltip: {
title: 'You do not have permission to create new projects',
},
disabled: true,
};
} else {
return {
tooltip: { title: 'Click to create a new project' },
disabled: false,
};
}
}
type ProjectCreationButtonProps = {
isDialogOpen: boolean;
setIsDialogOpen: (value: boolean) => void;
};
export const ProjectCreationButton: FC<ProjectCreationButtonProps> = ({
isDialogOpen,
setIsDialogOpen,
}) => {
const { hasAccess } = useContext(AccessContext);
const { isOss, loading } = useUiConfig();
const createButtonData = resolveCreateButtonData(
isOss(),
hasAccess(CREATE_PROJECT),
);
return (
<>
<ResponsiveButton
Icon={Add}
endIcon={createButtonData.endIcon}
onClick={() => setIsDialogOpen(true)}
maxWidth='700px'
permission={CREATE_PROJECT}
disabled={createButtonData.disabled || loading}
tooltipProps={createButtonData.tooltip}
data-testid={NAVIGATE_TO_CREATE_PROJECT}
>
New project
</ResponsiveButton>
<CreateProjectDialog
open={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
/>
</>
);
};

View File

@ -9,6 +9,7 @@ import loadingData from './loadingData';
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';
const StyledGridContainer = styled('div')(({ theme }) => ({
display: 'grid',
@ -30,7 +31,10 @@ type ProjectGroupProps = {
sectionTitle?: string;
projects: IProjectCard[];
loading: boolean;
searchValue: string;
/**
* @deprecated remove with projectListImprovements
*/
searchValue?: string;
placeholder?: string;
ProjectCardComponent?: ComponentType<IProjectCard & any>;
link?: boolean;
@ -49,6 +53,7 @@ export const ProjectGroup = ({
const ProjectCard =
ProjectCardComponent ??
(projectListImprovementsEnabled ? NewProjectCard : LegacyProjectCard);
const { searchQuery } = useSearchHighlightContext();
return (
<article>
@ -68,11 +73,11 @@ export const ProjectGroup = ({
condition={projects.length < 1 && !loading}
show={
<ConditionallyRender
condition={searchValue?.length > 0}
condition={(searchValue || searchQuery)?.length > 0}
show={
<TablePlaceholder>
No projects found matching &ldquo;
{searchValue}
{searchValue || searchQuery}
&rdquo;
</TablePlaceholder>
}

View File

@ -1,5 +1,5 @@
import { render } from 'utils/testRenderer';
import { ProjectListNew } from './ProjectList';
import { ProjectList } from './ProjectList';
import { screen, waitFor } from '@testing-library/react';
import { testServerRoute, testServerSetup } from 'utils/testServer';
import { CREATE_PROJECT } from '../../providers/AccessProvider/permissions';
@ -21,7 +21,7 @@ const setupApi = () => {
test('Enabled new project button when version and permission allow for it and limit is reached', async () => {
setupApi();
render(<ProjectListNew />, {
render(<ProjectList />, {
permissions: [{ permission: CREATE_PROJECT }],
});

View File

@ -1,31 +1,23 @@
import { type FC, useContext, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { type FC, useCallback } from 'react';
import useProjects from 'hooks/api/getters/useProjects/useProjects';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import type { IProjectCard } from 'interfaces/project';
import { PageContent } from 'component/common/PageContent/PageContent';
import AccessContext from 'contexts/AccessContext';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton';
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 { Link, styled, useMediaQuery } from '@mui/material';
import { Link as RouterLink } from 'react-router-dom';
import theme from 'themes/theme';
import { Search } from 'component/common/Search/Search';
import { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature';
import type { ITooltipResolverProps } from 'component/common/TooltipResolver/TooltipResolver';
import { ReactComponent as ProPlanIcon } from 'assets/icons/pro-enterprise-feature-badge.svg';
import { ReactComponent as ProPlanIconLight } from 'assets/icons/pro-enterprise-feature-badge-light.svg';
import { safeRegExp } from '@server/util/escape-regex';
import { ThemeMode } from 'component/common/ThemeMode/ThemeMode';
import { useProfile } from 'hooks/api/getters/useProfile/useProfile';
import { groupProjects } from './group-projects';
import { ProjectGroup } from './ProjectGroup';
import { CreateProjectDialog } from '../Project/CreateProject/NewCreateProjectForm/CreateProjectDialog';
import { useUiFlag } from 'hooks/useUiFlag';
import { ProjectsListSort } from './ProjectsListSort/ProjectsListSort';
import { useProjectsListState } from './hooks/useProjectsListState';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { ProjectList as LegacyProjectList } from './LegacyProjectList';
import { ProjectCreationButton } from './ProjectCreationButton/ProjectCreationButton';
import { useGroupedProjects } from './hooks/useGroupedProjects';
import { useProjectsSearchAndSort } from './hooks/useProjectsSearchAndSort';
const StyledApiError = styled(ApiError)(({ theme }) => ({
maxWidth: '500px',
@ -38,148 +30,33 @@ const StyledContainer = styled('div')(({ theme }) => ({
gap: theme.spacing(4),
}));
type PageQueryType = Partial<Record<'search', string>>;
interface ICreateButtonData {
disabled: boolean;
tooltip?: Omit<ITooltipResolverProps, 'children'>;
endIcon?: React.ReactNode;
}
const NAVIGATE_TO_CREATE_PROJECT = 'NAVIGATE_TO_CREATE_PROJECT';
function resolveCreateButtonData(
isOss: boolean,
hasAccess: boolean,
): ICreateButtonData {
if (isOss) {
return {
disabled: true,
tooltip: {
titleComponent: (
<PremiumFeature feature='adding-new-projects' tooltip />
),
sx: { maxWidth: '320px' },
variant: 'custom',
},
endIcon: (
<ThemeMode
darkmode={<ProPlanIconLight />}
lightmode={<ProPlanIcon />}
/>
),
};
} else if (!hasAccess) {
return {
tooltip: {
title: 'You do not have permission to create new projects',
},
disabled: true,
};
} else {
return {
tooltip: { title: 'Click to create a new project' },
disabled: false,
};
}
}
const ProjectCreationButton: FC = () => {
const [searchParams] = useSearchParams();
const showCreateDialog = Boolean(searchParams.get('create'));
const [openCreateDialog, setOpenCreateDialog] = useState(showCreateDialog);
const { hasAccess } = useContext(AccessContext);
const { isOss, loading } = useUiConfig();
const createButtonData = resolveCreateButtonData(
isOss(),
hasAccess(CREATE_PROJECT),
);
return (
<>
<ResponsiveButton
Icon={Add}
endIcon={createButtonData.endIcon}
onClick={() => setOpenCreateDialog(true)}
maxWidth='700px'
permission={CREATE_PROJECT}
disabled={createButtonData.disabled || loading}
tooltipProps={createButtonData.tooltip}
data-testid={NAVIGATE_TO_CREATE_PROJECT}
>
New project
</ResponsiveButton>
<CreateProjectDialog
open={openCreateDialog}
onClose={() => setOpenCreateDialog(false)}
/>
</>
);
};
export const ProjectListNew = () => {
const NewProjectList = () => {
const { projects, loading, error, refetch } = useProjects();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const [searchParams, setSearchParams] = useSearchParams();
const [searchValue, setSearchValue] = useState(
searchParams.get('search') || '',
);
const [state, setState] = useProjectsListState();
const archiveProjectsEnabled = useUiFlag('archiveProjects');
const myProjects = new Set(useProfile().profile?.projects || []);
useEffect(() => {
const tableState: PageQueryType = {};
if (searchValue) {
tableState.search = searchValue;
}
const setSearchValue = useCallback(
(value: string) => setState({ query: value || undefined }),
[setState],
);
setSearchParams(tableState, {
replace: true,
});
}, [searchValue, setSearchParams]);
const filteredProjects = useMemo(() => {
const regExp = safeRegExp(searchValue, 'i');
return (
searchValue
? projects.filter((project) => regExp.test(project.name))
: projects
).sort((a, b) => {
if (a?.favorite && !b?.favorite) {
return -1;
}
if (!a?.favorite && b?.favorite) {
return 1;
}
return 0;
});
}, [projects, searchValue]);
const groupedProjects = useMemo(() => {
return groupProjects(myProjects, filteredProjects);
}, [filteredProjects, myProjects]);
const sortedProjects = useProjectsSearchAndSort(
projects,
state.query,
state.sortBy,
);
const groupedProjects = useGroupedProjects(sortedProjects, myProjects);
const projectCount =
filteredProjects.length < projects.length
? `${filteredProjects.length} of ${projects.length}`
sortedProjects.length < projects.length
? `${sortedProjects.length} of ${projects.length}`
: projects.length;
const ProjectGroupComponent = (props: {
sectionTitle?: string;
projects: IProjectCard[];
}) => {
return (
<ProjectGroup
loading={loading}
searchValue={searchValue}
{...props}
/>
);
};
return (
<PageContent
isLoading={loading}
@ -193,7 +70,7 @@ export const ProjectListNew = () => {
show={
<>
<Search
initialValue={searchValue}
initialValue={state.query || ''}
onChange={setSearchValue}
/>
<PageHeader.Divider />
@ -214,8 +91,14 @@ export const ProjectListNew = () => {
</>
}
/>
<ProjectCreationButton />
<ProjectCreationButton
isDialogOpen={Boolean(state.create)}
setIsDialogOpen={(create) =>
setState({
create: create ? 'true' : undefined,
})
}
/>
</>
}
>
@ -223,7 +106,7 @@ export const ProjectListNew = () => {
condition={isSmallScreen}
show={
<Search
initialValue={searchValue}
initialValue={state.query || ''}
onChange={setSearchValue}
/>
}
@ -241,16 +124,36 @@ export const ProjectListNew = () => {
/>
)}
/>
<ProjectGroupComponent
sectionTitle='My projects'
projects={groupedProjects.myProjects}
<ProjectsListSort
sortBy={state.sortBy}
setSortBy={(sortBy) =>
setState({ sortBy: sortBy as typeof state.sortBy })
}
/>
<SearchHighlightProvider value={state.query || ''}>
<ProjectGroup
sectionTitle='My projects'
loading={loading}
projects={groupedProjects.myProjects}
/>
<ProjectGroupComponent
sectionTitle='Other projects'
projects={groupedProjects.otherProjects}
/>
<ProjectGroup
sectionTitle='Other projects'
loading={loading}
projects={groupedProjects.otherProjects}
/>
</SearchHighlightProvider>
</StyledContainer>
</PageContent>
);
};
export const ProjectList: FC = () => {
const projectListImprovementsEnabled = useUiFlag('projectListImprovements');
if (projectListImprovementsEnabled) {
return <NewProjectList />;
}
return <LegacyProjectList />;
};

View File

@ -0,0 +1,50 @@
import type { FC } from 'react';
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
import { styled } from '@mui/material';
const StyledWrapper = styled('div')(({ theme }) => ({
display: 'flex',
justifyContent: 'flex-end',
margin: theme.spacing(0, 0, -4, 0),
}));
const StyledContainer = styled('div')(() => ({
maxWidth: '220px',
width: '100%',
}));
export const sortKeys = ['name', 'created', 'updated', 'seen'] as const;
const options: Array<{
key: (typeof sortKeys)[number];
label: string;
}> = [
{ key: 'name', label: 'Project name' },
{ key: 'created', label: 'Recently created' },
{ key: 'updated', label: 'Recently updated' },
{ key: 'seen', label: 'Last usage reported' },
];
type ProjectsListSortProps = {
sortBy: string | null | undefined;
setSortBy: (value: string) => void;
};
export const ProjectsListSort: FC<ProjectsListSortProps> = ({
sortBy,
setSortBy,
}) => {
return (
<StyledWrapper>
<StyledContainer>
<GeneralSelect
fullWidth
label='Sort by'
onChange={setSortBy}
options={options}
value={sortBy || options[0].key}
/>
</StyledContainer>
</StyledWrapper>
);
};

View File

@ -0,0 +1,12 @@
import { useMemo } from 'react';
import { groupProjects } from '../group-projects';
import type { IProjectCard } from 'interfaces/project';
export const useGroupedProjects = (
filteredAndSortedProjects: IProjectCard[],
myProjects: Set<string>,
) =>
useMemo(
() => groupProjects(myProjects, filteredAndSortedProjects),
[filteredAndSortedProjects, myProjects],
);

View File

@ -0,0 +1,20 @@
import { usePersistentTableState } from 'hooks/usePersistentTableState';
import {
createEnumParam,
type QueryParamConfig,
StringParam,
withDefault,
} from 'use-query-params';
import { sortKeys } from '../ProjectsListSort/ProjectsListSort';
const stateConfig = {
query: StringParam,
sortBy: withDefault(
createEnumParam([...sortKeys]),
sortKeys[0],
) as QueryParamConfig<(typeof sortKeys)[number] | null | undefined>,
create: StringParam,
} as const;
export const useProjectsListState = () =>
usePersistentTableState(`projects-list`, stateConfig, ['create']);

View File

@ -0,0 +1,222 @@
import { renderHook } from '@testing-library/react';
import { useProjectsSearchAndSort } from './useProjectsSearchAndSort';
import type { IProjectCard } from 'interfaces/project';
const projects: IProjectCard[] = [
{
name: 'A - Eagle',
id: '1',
createdAt: '2024-01-01',
lastUpdatedAt: '2024-01-10',
lastReportedFlagUsage: '2024-01-15',
favorite: false,
},
{
name: 'B - Horse',
id: '2',
createdAt: '2024-02-01',
lastUpdatedAt: '2024-02-10',
lastReportedFlagUsage: '2024-02-15',
},
{
name: 'C - Koala',
id: '3',
createdAt: '2024-01-15',
lastUpdatedAt: '2024-01-20',
lastReportedFlagUsage: '2024-01-25',
favorite: false,
},
{
name: 'D - Shark',
id: '4',
createdAt: '2024-03-01',
lastUpdatedAt: '2024-03-10',
lastReportedFlagUsage: '2024-03-15',
favorite: true,
},
{
name: 'E - Tiger',
id: '5',
createdAt: '2024-01-20',
lastUpdatedAt: '2024-01-30',
lastReportedFlagUsage: '2024-02-05',
},
{
name: 'F - Zebra',
id: '6',
createdAt: '2024-02-15',
lastUpdatedAt: '2024-02-20',
lastReportedFlagUsage: '2024-02-25',
favorite: true,
},
];
describe('useProjectsSearchAndSort', () => {
it('should handle projects with no sorting key (default behavior)', () => {
const { result } = renderHook(() => useProjectsSearchAndSort(projects));
expect(
result.current.map(
(project) =>
`${project.name}${project.favorite ? ' - favorite' : ''}`,
),
).toEqual([
'D - Shark - favorite',
'F - Zebra - favorite',
'A - Eagle',
'B - Horse',
'C - Koala',
'E - Tiger',
]);
});
it('should return projects sorted by creation date in descending order', () => {
const { result } = renderHook(() =>
useProjectsSearchAndSort(projects, undefined, 'created'),
);
expect(
result.current.map(
(project) =>
`${project.name} - ${project.createdAt}${project.favorite ? ' - favorite' : ''}`,
),
).toEqual([
'D - Shark - 2024-03-01 - favorite',
'F - Zebra - 2024-02-15 - favorite',
'B - Horse - 2024-02-01',
'E - Tiger - 2024-01-20',
'C - Koala - 2024-01-15',
'A - Eagle - 2024-01-01',
]);
});
it('should return projects sorted by last updated date in descending order', () => {
const { result } = renderHook(() =>
useProjectsSearchAndSort(projects, undefined, 'updated'),
);
expect(
result.current.map(
(project) =>
`${project.name} - ${project.lastUpdatedAt}${project.favorite ? ' - favorite' : ''}`,
),
).toEqual([
'D - Shark - 2024-03-10 - favorite',
'F - Zebra - 2024-02-20 - favorite',
'B - Horse - 2024-02-10',
'E - Tiger - 2024-01-30',
'C - Koala - 2024-01-20',
'A - Eagle - 2024-01-10',
]);
});
it('should return projects sorted by last reported flag usage in descending order', () => {
const { result } = renderHook(() =>
useProjectsSearchAndSort(projects, undefined, 'seen'),
);
expect(
result.current.map(
(project) =>
`${project.name} - ${project.lastReportedFlagUsage}${project.favorite ? ' - favorite' : ''}`,
),
).toEqual([
'D - Shark - 2024-03-15 - favorite',
'F - Zebra - 2024-02-25 - favorite',
'B - Horse - 2024-02-15',
'E - Tiger - 2024-02-05',
'C - Koala - 2024-01-25',
'A - Eagle - 2024-01-15',
]);
});
it('should filter projects by query and return sorted by name', () => {
const { result } = renderHook(() =>
useProjectsSearchAndSort(projects, 'e', 'name'),
);
expect(
result.current.map(
(project) =>
`${project.name}${project.favorite ? ' - favorite' : ''}`,
),
).toEqual([
'F - Zebra - favorite',
'A - Eagle',
'B - Horse',
'E - Tiger',
]);
});
it('should handle query that does not match any projects', () => {
const { result } = renderHook(() =>
useProjectsSearchAndSort(projects, 'Nonexistent'),
);
expect(result.current).toEqual([]);
});
it('should handle query that matches some projects', () => {
const { result } = renderHook(() =>
useProjectsSearchAndSort(projects, 'R'),
);
expect(
result.current.map(
(project) =>
`${project.name}${project.favorite ? ' - favorite' : ''}`,
),
).toEqual([
'D - Shark - favorite',
'F - Zebra - favorite',
'B - Horse',
'E - Tiger',
]);
});
it('should be able to deal with date', () => {
const hook = renderHook(
(sortBy: string) =>
useProjectsSearchAndSort(
[
{
name: 'Project A',
id: '1',
createdAt: '2024-01-01',
lastUpdatedAt: '2024-03-10',
lastReportedFlagUsage: '2024-01-15',
},
{
name: 'Project B',
id: '2',
createdAt: new Date('2024-02-01'),
lastUpdatedAt: new Date('2024-02-10'),
lastReportedFlagUsage: new Date('2024-02-15'),
},
],
undefined,
sortBy as any,
),
{
initialProps: 'created',
},
);
expect(hook.result.current.map((project) => project.name)).toEqual([
'Project B',
'Project A',
]);
hook.rerender('updated');
expect(hook.result.current.map((project) => project.name)).toEqual([
'Project A',
'Project B',
]);
hook.rerender('seen');
expect(hook.result.current.map((project) => project.name)).toEqual([
'Project B',
'Project A',
]);
});
});

View File

@ -0,0 +1,50 @@
import { useMemo } from 'react';
import { safeRegExp } from '@server/util/escape-regex';
import type { IProjectCard } from 'interfaces/project';
import type { sortKeys } from '../ProjectsListSort/ProjectsListSort';
export const useProjectsSearchAndSort = (
projects: IProjectCard[],
query?: string | null,
sortBy?: (typeof sortKeys)[number] | null,
) =>
useMemo(() => {
const regExp = safeRegExp(query || '', 'i');
return (
query
? projects.filter((project) => regExp.test(project.name))
: projects
)
.sort((a, b) => {
if (sortBy === 'created') {
const aVal = new Date(a.createdAt || 0);
const bVal = new Date(b.createdAt || 0);
return bVal?.getTime() - aVal?.getTime();
}
if (sortBy === 'updated') {
const aVal = new Date(a.lastUpdatedAt || 0);
const bVal = new Date(b.lastUpdatedAt || 0);
return bVal?.getTime() - aVal?.getTime();
}
if (sortBy === 'seen') {
const aVal = new Date(a.lastReportedFlagUsage || 0);
const bVal = new Date(b.lastReportedFlagUsage || 0);
return bVal?.getTime() - aVal?.getTime();
}
const aVal = `${a.name || ''}`.toLowerCase();
const bVal = `${b.name || ''}`.toLowerCase();
return aVal?.localeCompare(bVal);
})
.sort((a, b) => {
if (a?.favorite && !b?.favorite) {
return -1;
}
if (!a?.favorite && b?.favorite) {
return 1;
}
return 0;
});
}, [projects, query, sortBy]);

View File

@ -9,12 +9,18 @@ import { FilterItemParam } from '../utils/serializeQueryParams';
type TestComponentProps = {
keyName: string;
queryParamsDefinition: Record<string, any>;
nonPersistentParams?: string[];
};
function TestComponent({ keyName, queryParamsDefinition }: TestComponentProps) {
function TestComponent({
keyName,
queryParamsDefinition,
nonPersistentParams,
}: TestComponentProps) {
const [tableState, setTableState] = usePersistentTableState(
keyName,
queryParamsDefinition,
nonPersistentParams,
);
return (

View File

@ -34,6 +34,7 @@ const usePersistentSearchParams = <T extends QueryParamConfigMap>(
export const usePersistentTableState = <T extends QueryParamConfigMap>(
key: string,
queryParamsDefinition: T,
excludedFromStorage: string[] = ['offset'],
) => {
const updateStoredParams = usePersistentSearchParams(
key,
@ -83,8 +84,12 @@ export const usePersistentTableState = <T extends QueryParamConfigMap>(
);
useEffect(() => {
const { offset, ...rest } = orderedTableState;
updateStoredParams(rest);
const filteredTableState = Object.fromEntries(
Object.entries(orderedTableState).filter(
([key]) => !excludedFromStorage.includes(key),
),
);
updateStoredParams(filteredTableState);
}, [JSON.stringify(orderedTableState)]);
return [orderedTableState, setTableState] as const;

View File

@ -6,7 +6,7 @@ import type { ProjectMode } from 'component/project/Project/hooks/useProjectEnte
export interface IProjectCard {
name: string;
id: string;
createdAt: string;
createdAt: string | Date;
health?: number;
description?: string;
featureCount?: number;
@ -15,8 +15,8 @@ export interface IProjectCard {
onHover?: () => void;
favorite?: boolean;
owners?: ProjectSchema['owners'];
lastUpdatedAt?: Date;
lastReportedFlagUsage?: Date;
lastUpdatedAt?: Date | string;
lastReportedFlagUsage?: Date | string;
}
export type FeatureNamingType = {