mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-10 01:16:39 +02:00
parent
c5d6bdecac
commit
8b68a0657f
@ -8,7 +8,7 @@ import { EEA, P } from 'component/common/flags';
|
|||||||
import { NewUser } from 'component/user/NewUser/NewUser';
|
import { NewUser } from 'component/user/NewUser/NewUser';
|
||||||
import ResetPassword from 'component/user/ResetPassword/ResetPassword';
|
import ResetPassword from 'component/user/ResetPassword/ResetPassword';
|
||||||
import ForgottenPassword from 'component/user/ForgottenPassword/ForgottenPassword';
|
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 { ArchiveProjectList } from 'component/project/ProjectList/ArchiveProjectList';
|
||||||
import RedirectArchive from 'component/archive/RedirectArchive';
|
import RedirectArchive from 'component/archive/RedirectArchive';
|
||||||
import CreateEnvironment from 'component/environments/CreateEnvironment/CreateEnvironment';
|
import CreateEnvironment from 'component/environments/CreateEnvironment/CreateEnvironment';
|
||||||
@ -113,7 +113,7 @@ export const routes: IRoute[] = [
|
|||||||
{
|
{
|
||||||
path: '/projects',
|
path: '/projects',
|
||||||
title: 'Projects',
|
title: 'Projects',
|
||||||
component: ProjectListNew,
|
component: ProjectList,
|
||||||
type: 'protected',
|
type: 'protected',
|
||||||
menu: { mobile: true },
|
menu: { mobile: true },
|
||||||
},
|
},
|
||||||
|
@ -15,6 +15,8 @@ import { flexColumn } from 'themes/themeStyles';
|
|||||||
import { TimeAgo } from 'component/common/TimeAgo/TimeAgo';
|
import { TimeAgo } from 'component/common/TimeAgo/TimeAgo';
|
||||||
import { ProjectLastSeen } from './ProjectLastSeen/ProjectLastSeen';
|
import { ProjectLastSeen } from './ProjectLastSeen/ProjectLastSeen';
|
||||||
import type { IProjectCard } from 'interfaces/project';
|
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 }) => ({
|
const StyledUpdated = styled('span')(({ theme }) => ({
|
||||||
color: theme.palette.text.secondary,
|
color: theme.palette.text.secondary,
|
||||||
@ -45,7 +47,10 @@ export const ProjectCard = ({
|
|||||||
owners,
|
owners,
|
||||||
lastUpdatedAt,
|
lastUpdatedAt,
|
||||||
lastReportedFlagUsage,
|
lastReportedFlagUsage,
|
||||||
}: IProjectCard) => (
|
}: IProjectCard) => {
|
||||||
|
const { searchQuery } = useSearchHighlightContext();
|
||||||
|
|
||||||
|
return (
|
||||||
<StyledProjectCard onMouseEnter={onHover}>
|
<StyledProjectCard onMouseEnter={onHover}>
|
||||||
<StyledProjectCardBody>
|
<StyledProjectCardBody>
|
||||||
<StyledDivHeader>
|
<StyledDivHeader>
|
||||||
@ -60,7 +65,9 @@ export const ProjectCard = ({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<StyledCardTitle lines={1} sx={{ margin: 0 }}>
|
<StyledCardTitle lines={1} sx={{ margin: 0 }}>
|
||||||
|
<Highlighter search={searchQuery}>
|
||||||
{name}
|
{name}
|
||||||
|
</Highlighter>
|
||||||
</StyledCardTitle>
|
</StyledCardTitle>
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={Boolean(lastUpdatedAt)}
|
condition={Boolean(lastUpdatedAt)}
|
||||||
@ -87,6 +94,11 @@ export const ProjectCard = ({
|
|||||||
<ProjectLastSeen date={lastReportedFlagUsage} />
|
<ProjectLastSeen date={lastReportedFlagUsage} />
|
||||||
</StyledInfo>
|
</StyledInfo>
|
||||||
</StyledProjectCardBody>
|
</StyledProjectCardBody>
|
||||||
<ProjectCardFooter id={id} owners={owners} memberCount={memberCount} />
|
<ProjectCardFooter
|
||||||
|
id={id}
|
||||||
|
owners={owners}
|
||||||
|
memberCount={memberCount}
|
||||||
|
/>
|
||||||
</StyledProjectCard>
|
</StyledProjectCard>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
256
frontend/src/component/project/ProjectList/LegacyProjectList.tsx
Normal file
256
frontend/src/component/project/ProjectList/LegacyProjectList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
@ -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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -9,6 +9,7 @@ import loadingData from './loadingData';
|
|||||||
import { TablePlaceholder } from 'component/common/Table';
|
import { TablePlaceholder } from 'component/common/Table';
|
||||||
import { styled, Typography } from '@mui/material';
|
import { styled, Typography } from '@mui/material';
|
||||||
import { useUiFlag } from 'hooks/useUiFlag';
|
import { useUiFlag } from 'hooks/useUiFlag';
|
||||||
|
import { useSearchHighlightContext } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
|
||||||
|
|
||||||
const StyledGridContainer = styled('div')(({ theme }) => ({
|
const StyledGridContainer = styled('div')(({ theme }) => ({
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@ -30,7 +31,10 @@ type ProjectGroupProps = {
|
|||||||
sectionTitle?: string;
|
sectionTitle?: string;
|
||||||
projects: IProjectCard[];
|
projects: IProjectCard[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
searchValue: string;
|
/**
|
||||||
|
* @deprecated remove with projectListImprovements
|
||||||
|
*/
|
||||||
|
searchValue?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
ProjectCardComponent?: ComponentType<IProjectCard & any>;
|
ProjectCardComponent?: ComponentType<IProjectCard & any>;
|
||||||
link?: boolean;
|
link?: boolean;
|
||||||
@ -49,6 +53,7 @@ export const ProjectGroup = ({
|
|||||||
const ProjectCard =
|
const ProjectCard =
|
||||||
ProjectCardComponent ??
|
ProjectCardComponent ??
|
||||||
(projectListImprovementsEnabled ? NewProjectCard : LegacyProjectCard);
|
(projectListImprovementsEnabled ? NewProjectCard : LegacyProjectCard);
|
||||||
|
const { searchQuery } = useSearchHighlightContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article>
|
<article>
|
||||||
@ -68,11 +73,11 @@ export const ProjectGroup = ({
|
|||||||
condition={projects.length < 1 && !loading}
|
condition={projects.length < 1 && !loading}
|
||||||
show={
|
show={
|
||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={searchValue?.length > 0}
|
condition={(searchValue || searchQuery)?.length > 0}
|
||||||
show={
|
show={
|
||||||
<TablePlaceholder>
|
<TablePlaceholder>
|
||||||
No projects found matching “
|
No projects found matching “
|
||||||
{searchValue}
|
{searchValue || searchQuery}
|
||||||
”
|
”
|
||||||
</TablePlaceholder>
|
</TablePlaceholder>
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { render } from 'utils/testRenderer';
|
import { render } from 'utils/testRenderer';
|
||||||
import { ProjectListNew } from './ProjectList';
|
import { ProjectList } from './ProjectList';
|
||||||
import { screen, waitFor } from '@testing-library/react';
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
import { testServerRoute, testServerSetup } from 'utils/testServer';
|
import { testServerRoute, testServerSetup } from 'utils/testServer';
|
||||||
import { CREATE_PROJECT } from '../../providers/AccessProvider/permissions';
|
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 () => {
|
test('Enabled new project button when version and permission allow for it and limit is reached', async () => {
|
||||||
setupApi();
|
setupApi();
|
||||||
render(<ProjectListNew />, {
|
render(<ProjectList />, {
|
||||||
permissions: [{ permission: CREATE_PROJECT }],
|
permissions: [{ permission: CREATE_PROJECT }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,31 +1,23 @@
|
|||||||
import { type FC, useContext, useEffect, useMemo, useState } from 'react';
|
import { type FC, useCallback } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import useProjects from 'hooks/api/getters/useProjects/useProjects';
|
import useProjects from 'hooks/api/getters/useProjects/useProjects';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
import type { IProjectCard } from 'interfaces/project';
|
|
||||||
import { PageContent } from 'component/common/PageContent/PageContent';
|
import { PageContent } from 'component/common/PageContent/PageContent';
|
||||||
import AccessContext from 'contexts/AccessContext';
|
|
||||||
import { PageHeader } from 'component/common/PageHeader/PageHeader';
|
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 ApiError from 'component/common/ApiError/ApiError';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
|
||||||
import { Link, styled, useMediaQuery } from '@mui/material';
|
import { Link, styled, useMediaQuery } from '@mui/material';
|
||||||
import { Link as RouterLink } from 'react-router-dom';
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
import theme from 'themes/theme';
|
import theme from 'themes/theme';
|
||||||
import { Search } from 'component/common/Search/Search';
|
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 { useProfile } from 'hooks/api/getters/useProfile/useProfile';
|
||||||
import { groupProjects } from './group-projects';
|
|
||||||
import { ProjectGroup } from './ProjectGroup';
|
import { ProjectGroup } from './ProjectGroup';
|
||||||
import { CreateProjectDialog } from '../Project/CreateProject/NewCreateProjectForm/CreateProjectDialog';
|
|
||||||
import { useUiFlag } from 'hooks/useUiFlag';
|
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 }) => ({
|
const StyledApiError = styled(ApiError)(({ theme }) => ({
|
||||||
maxWidth: '500px',
|
maxWidth: '500px',
|
||||||
@ -38,148 +30,33 @@ const StyledContainer = styled('div')(({ theme }) => ({
|
|||||||
gap: theme.spacing(4),
|
gap: theme.spacing(4),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
type PageQueryType = Partial<Record<'search', string>>;
|
const NewProjectList = () => {
|
||||||
|
|
||||||
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 { projects, loading, error, refetch } = useProjects();
|
const { projects, loading, error, refetch } = useProjects();
|
||||||
|
|
||||||
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const [searchValue, setSearchValue] = useState(
|
const [state, setState] = useProjectsListState();
|
||||||
searchParams.get('search') || '',
|
|
||||||
);
|
|
||||||
const archiveProjectsEnabled = useUiFlag('archiveProjects');
|
const archiveProjectsEnabled = useUiFlag('archiveProjects');
|
||||||
|
|
||||||
const myProjects = new Set(useProfile().profile?.projects || []);
|
const myProjects = new Set(useProfile().profile?.projects || []);
|
||||||
|
|
||||||
useEffect(() => {
|
const setSearchValue = useCallback(
|
||||||
const tableState: PageQueryType = {};
|
(value: string) => setState({ query: value || undefined }),
|
||||||
if (searchValue) {
|
[setState],
|
||||||
tableState.search = searchValue;
|
);
|
||||||
}
|
|
||||||
|
|
||||||
setSearchParams(tableState, {
|
const sortedProjects = useProjectsSearchAndSort(
|
||||||
replace: true,
|
projects,
|
||||||
});
|
state.query,
|
||||||
}, [searchValue, setSearchParams]);
|
state.sortBy,
|
||||||
|
);
|
||||||
const filteredProjects = useMemo(() => {
|
const groupedProjects = useGroupedProjects(sortedProjects, myProjects);
|
||||||
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 =
|
const projectCount =
|
||||||
filteredProjects.length < projects.length
|
sortedProjects.length < projects.length
|
||||||
? `${filteredProjects.length} of ${projects.length}`
|
? `${sortedProjects.length} of ${projects.length}`
|
||||||
: projects.length;
|
: projects.length;
|
||||||
|
|
||||||
const ProjectGroupComponent = (props: {
|
|
||||||
sectionTitle?: string;
|
|
||||||
projects: IProjectCard[];
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<ProjectGroup
|
|
||||||
loading={loading}
|
|
||||||
searchValue={searchValue}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContent
|
<PageContent
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
@ -193,7 +70,7 @@ export const ProjectListNew = () => {
|
|||||||
show={
|
show={
|
||||||
<>
|
<>
|
||||||
<Search
|
<Search
|
||||||
initialValue={searchValue}
|
initialValue={state.query || ''}
|
||||||
onChange={setSearchValue}
|
onChange={setSearchValue}
|
||||||
/>
|
/>
|
||||||
<PageHeader.Divider />
|
<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}
|
condition={isSmallScreen}
|
||||||
show={
|
show={
|
||||||
<Search
|
<Search
|
||||||
initialValue={searchValue}
|
initialValue={state.query || ''}
|
||||||
onChange={setSearchValue}
|
onChange={setSearchValue}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@ -241,16 +124,36 @@ export const ProjectListNew = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<ProjectGroupComponent
|
<ProjectsListSort
|
||||||
|
sortBy={state.sortBy}
|
||||||
|
setSortBy={(sortBy) =>
|
||||||
|
setState({ sortBy: sortBy as typeof state.sortBy })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SearchHighlightProvider value={state.query || ''}>
|
||||||
|
<ProjectGroup
|
||||||
sectionTitle='My projects'
|
sectionTitle='My projects'
|
||||||
|
loading={loading}
|
||||||
projects={groupedProjects.myProjects}
|
projects={groupedProjects.myProjects}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ProjectGroupComponent
|
<ProjectGroup
|
||||||
sectionTitle='Other projects'
|
sectionTitle='Other projects'
|
||||||
|
loading={loading}
|
||||||
projects={groupedProjects.otherProjects}
|
projects={groupedProjects.otherProjects}
|
||||||
/>
|
/>
|
||||||
|
</SearchHighlightProvider>
|
||||||
</StyledContainer>
|
</StyledContainer>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ProjectList: FC = () => {
|
||||||
|
const projectListImprovementsEnabled = useUiFlag('projectListImprovements');
|
||||||
|
|
||||||
|
if (projectListImprovementsEnabled) {
|
||||||
|
return <NewProjectList />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LegacyProjectList />;
|
||||||
|
};
|
||||||
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
@ -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],
|
||||||
|
);
|
@ -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']);
|
@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
@ -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]);
|
@ -9,12 +9,18 @@ import { FilterItemParam } from '../utils/serializeQueryParams';
|
|||||||
type TestComponentProps = {
|
type TestComponentProps = {
|
||||||
keyName: string;
|
keyName: string;
|
||||||
queryParamsDefinition: Record<string, any>;
|
queryParamsDefinition: Record<string, any>;
|
||||||
|
nonPersistentParams?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function TestComponent({ keyName, queryParamsDefinition }: TestComponentProps) {
|
function TestComponent({
|
||||||
|
keyName,
|
||||||
|
queryParamsDefinition,
|
||||||
|
nonPersistentParams,
|
||||||
|
}: TestComponentProps) {
|
||||||
const [tableState, setTableState] = usePersistentTableState(
|
const [tableState, setTableState] = usePersistentTableState(
|
||||||
keyName,
|
keyName,
|
||||||
queryParamsDefinition,
|
queryParamsDefinition,
|
||||||
|
nonPersistentParams,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -34,6 +34,7 @@ const usePersistentSearchParams = <T extends QueryParamConfigMap>(
|
|||||||
export const usePersistentTableState = <T extends QueryParamConfigMap>(
|
export const usePersistentTableState = <T extends QueryParamConfigMap>(
|
||||||
key: string,
|
key: string,
|
||||||
queryParamsDefinition: T,
|
queryParamsDefinition: T,
|
||||||
|
excludedFromStorage: string[] = ['offset'],
|
||||||
) => {
|
) => {
|
||||||
const updateStoredParams = usePersistentSearchParams(
|
const updateStoredParams = usePersistentSearchParams(
|
||||||
key,
|
key,
|
||||||
@ -83,8 +84,12 @@ export const usePersistentTableState = <T extends QueryParamConfigMap>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { offset, ...rest } = orderedTableState;
|
const filteredTableState = Object.fromEntries(
|
||||||
updateStoredParams(rest);
|
Object.entries(orderedTableState).filter(
|
||||||
|
([key]) => !excludedFromStorage.includes(key),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
updateStoredParams(filteredTableState);
|
||||||
}, [JSON.stringify(orderedTableState)]);
|
}, [JSON.stringify(orderedTableState)]);
|
||||||
|
|
||||||
return [orderedTableState, setTableState] as const;
|
return [orderedTableState, setTableState] as const;
|
||||||
|
@ -6,7 +6,7 @@ import type { ProjectMode } from 'component/project/Project/hooks/useProjectEnte
|
|||||||
export interface IProjectCard {
|
export interface IProjectCard {
|
||||||
name: string;
|
name: string;
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: string;
|
createdAt: string | Date;
|
||||||
health?: number;
|
health?: number;
|
||||||
description?: string;
|
description?: string;
|
||||||
featureCount?: number;
|
featureCount?: number;
|
||||||
@ -15,8 +15,8 @@ export interface IProjectCard {
|
|||||||
onHover?: () => void;
|
onHover?: () => void;
|
||||||
favorite?: boolean;
|
favorite?: boolean;
|
||||||
owners?: ProjectSchema['owners'];
|
owners?: ProjectSchema['owners'];
|
||||||
lastUpdatedAt?: Date;
|
lastUpdatedAt?: Date | string;
|
||||||
lastReportedFlagUsage?: Date;
|
lastReportedFlagUsage?: Date | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeatureNamingType = {
|
export type FeatureNamingType = {
|
||||||
|
Loading…
Reference in New Issue
Block a user