mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01: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,48 +47,58 @@ export const ProjectCard = ({
 | 
				
			|||||||
    owners,
 | 
					    owners,
 | 
				
			||||||
    lastUpdatedAt,
 | 
					    lastUpdatedAt,
 | 
				
			||||||
    lastReportedFlagUsage,
 | 
					    lastReportedFlagUsage,
 | 
				
			||||||
}: IProjectCard) => (
 | 
					}: IProjectCard) => {
 | 
				
			||||||
    <StyledProjectCard onMouseEnter={onHover}>
 | 
					    const { searchQuery } = useSearchHighlightContext();
 | 
				
			||||||
        <StyledProjectCardBody>
 | 
					
 | 
				
			||||||
            <StyledDivHeader>
 | 
					    return (
 | 
				
			||||||
                <StyledIconBox>
 | 
					        <StyledProjectCard onMouseEnter={onHover}>
 | 
				
			||||||
                    <ProjectIcon />
 | 
					            <StyledProjectCardBody>
 | 
				
			||||||
                </StyledIconBox>
 | 
					                <StyledDivHeader>
 | 
				
			||||||
                <Box
 | 
					                    <StyledIconBox>
 | 
				
			||||||
                    data-loading
 | 
					                        <ProjectIcon />
 | 
				
			||||||
                    sx={(theme) => ({
 | 
					                    </StyledIconBox>
 | 
				
			||||||
                        ...flexColumn,
 | 
					                    <Box
 | 
				
			||||||
                        margin: theme.spacing(1, 'auto', 1, 0),
 | 
					                        data-loading
 | 
				
			||||||
                    })}
 | 
					                        sx={(theme) => ({
 | 
				
			||||||
                >
 | 
					                            ...flexColumn,
 | 
				
			||||||
                    <StyledCardTitle lines={1} sx={{ margin: 0 }}>
 | 
					                            margin: theme.spacing(1, 'auto', 1, 0),
 | 
				
			||||||
                        {name}
 | 
					                        })}
 | 
				
			||||||
                    </StyledCardTitle>
 | 
					                    >
 | 
				
			||||||
                    <ConditionallyRender
 | 
					                        <StyledCardTitle lines={1} sx={{ margin: 0 }}>
 | 
				
			||||||
                        condition={Boolean(lastUpdatedAt)}
 | 
					                            <Highlighter search={searchQuery}>
 | 
				
			||||||
                        show={
 | 
					                                {name}
 | 
				
			||||||
                            <StyledUpdated>
 | 
					                            </Highlighter>
 | 
				
			||||||
                                Updated <TimeAgo date={lastUpdatedAt} />
 | 
					                        </StyledCardTitle>
 | 
				
			||||||
                            </StyledUpdated>
 | 
					                        <ConditionallyRender
 | 
				
			||||||
                        }
 | 
					                            condition={Boolean(lastUpdatedAt)}
 | 
				
			||||||
                    />
 | 
					                            show={
 | 
				
			||||||
                </Box>
 | 
					                                <StyledUpdated>
 | 
				
			||||||
                <ProjectModeBadge mode={mode} />
 | 
					                                    Updated <TimeAgo date={lastUpdatedAt} />
 | 
				
			||||||
                <FavoriteAction id={id} isFavorite={favorite} />
 | 
					                                </StyledUpdated>
 | 
				
			||||||
            </StyledDivHeader>
 | 
					                            }
 | 
				
			||||||
            <StyledInfo>
 | 
					                        />
 | 
				
			||||||
                <div>
 | 
					                    </Box>
 | 
				
			||||||
 | 
					                    <ProjectModeBadge mode={mode} />
 | 
				
			||||||
 | 
					                    <FavoriteAction id={id} isFavorite={favorite} />
 | 
				
			||||||
 | 
					                </StyledDivHeader>
 | 
				
			||||||
 | 
					                <StyledInfo>
 | 
				
			||||||
                    <div>
 | 
					                    <div>
 | 
				
			||||||
                        <StyledCount>{featureCount}</StyledCount> flag
 | 
					                        <div>
 | 
				
			||||||
                        {featureCount === 1 ? '' : 's'}
 | 
					                            <StyledCount>{featureCount}</StyledCount> flag
 | 
				
			||||||
 | 
					                            {featureCount === 1 ? '' : 's'}
 | 
				
			||||||
 | 
					                        </div>
 | 
				
			||||||
 | 
					                        <div>
 | 
				
			||||||
 | 
					                            <StyledCount>{health}%</StyledCount> health
 | 
				
			||||||
 | 
					                        </div>
 | 
				
			||||||
                    </div>
 | 
					                    </div>
 | 
				
			||||||
                    <div>
 | 
					                    <ProjectLastSeen date={lastReportedFlagUsage} />
 | 
				
			||||||
                        <StyledCount>{health}%</StyledCount> health
 | 
					                </StyledInfo>
 | 
				
			||||||
                    </div>
 | 
					            </StyledProjectCardBody>
 | 
				
			||||||
                </div>
 | 
					            <ProjectCardFooter
 | 
				
			||||||
                <ProjectLastSeen date={lastReportedFlagUsage} />
 | 
					                id={id}
 | 
				
			||||||
            </StyledInfo>
 | 
					                owners={owners}
 | 
				
			||||||
        </StyledProjectCardBody>
 | 
					                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
 | 
				
			||||||
                    sectionTitle='My projects'
 | 
					                    sortBy={state.sortBy}
 | 
				
			||||||
                    projects={groupedProjects.myProjects}
 | 
					                    setSortBy={(sortBy) =>
 | 
				
			||||||
 | 
					                        setState({ sortBy: sortBy as typeof state.sortBy })
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
                />
 | 
					                />
 | 
				
			||||||
 | 
					                <SearchHighlightProvider value={state.query || ''}>
 | 
				
			||||||
 | 
					                    <ProjectGroup
 | 
				
			||||||
 | 
					                        sectionTitle='My projects'
 | 
				
			||||||
 | 
					                        loading={loading}
 | 
				
			||||||
 | 
					                        projects={groupedProjects.myProjects}
 | 
				
			||||||
 | 
					                    />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                <ProjectGroupComponent
 | 
					                    <ProjectGroup
 | 
				
			||||||
                    sectionTitle='Other projects'
 | 
					                        sectionTitle='Other projects'
 | 
				
			||||||
                    projects={groupedProjects.otherProjects}
 | 
					                        loading={loading}
 | 
				
			||||||
                />
 | 
					                        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