@@ -68,11 +73,11 @@ export const ProjectGroup = ({ condition={projects.length < 1 && !loading} show={ 0} + condition={(searchValue || searchQuery)?.length > 0} show={ No projects found matching “ - {searchValue} + {searchValue || searchQuery} ” } diff --git a/frontend/src/component/project/ProjectList/ProjectList.test.tsx b/frontend/src/component/project/ProjectList/ProjectList.test.tsx index b3c5bc658b..4917b7e31a 100644 --- a/frontend/src/component/project/ProjectList/ProjectList.test.tsx +++ b/frontend/src/component/project/ProjectList/ProjectList.test.tsx @@ -1,5 +1,5 @@ import { render } from 'utils/testRenderer'; -import { ProjectListNew } from './ProjectList'; +import { ProjectList } from './ProjectList'; import { screen, waitFor } from '@testing-library/react'; import { testServerRoute, testServerSetup } from 'utils/testServer'; import { CREATE_PROJECT } from '../../providers/AccessProvider/permissions'; @@ -21,7 +21,7 @@ const setupApi = () => { test('Enabled new project button when version and permission allow for it and limit is reached', async () => { setupApi(); - render(, { + render(, { permissions: [{ permission: CREATE_PROJECT }], }); diff --git a/frontend/src/component/project/ProjectList/ProjectList.tsx b/frontend/src/component/project/ProjectList/ProjectList.tsx index 1e9634e946..ac890ab3d7 100644 --- a/frontend/src/component/project/ProjectList/ProjectList.tsx +++ b/frontend/src/component/project/ProjectList/ProjectList.tsx @@ -1,31 +1,23 @@ -import { type FC, useContext, useEffect, useMemo, useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import { type FC, useCallback } from 'react'; import useProjects from 'hooks/api/getters/useProjects/useProjects'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -import type { IProjectCard } from 'interfaces/project'; import { PageContent } from 'component/common/PageContent/PageContent'; -import AccessContext from 'contexts/AccessContext'; import { PageHeader } from 'component/common/PageHeader/PageHeader'; -import ResponsiveButton from 'component/common/ResponsiveButton/ResponsiveButton'; -import { CREATE_PROJECT } from 'component/providers/AccessProvider/permissions'; -import Add from '@mui/icons-material/Add'; import ApiError from 'component/common/ApiError/ApiError'; -import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { Link, styled, useMediaQuery } from '@mui/material'; import { Link as RouterLink } from 'react-router-dom'; import theme from 'themes/theme'; import { Search } from 'component/common/Search/Search'; -import { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature'; -import type { ITooltipResolverProps } from 'component/common/TooltipResolver/TooltipResolver'; -import { ReactComponent as ProPlanIcon } from 'assets/icons/pro-enterprise-feature-badge.svg'; -import { ReactComponent as ProPlanIconLight } from 'assets/icons/pro-enterprise-feature-badge-light.svg'; -import { safeRegExp } from '@server/util/escape-regex'; -import { ThemeMode } from 'component/common/ThemeMode/ThemeMode'; import { useProfile } from 'hooks/api/getters/useProfile/useProfile'; -import { groupProjects } from './group-projects'; import { ProjectGroup } from './ProjectGroup'; -import { CreateProjectDialog } from '../Project/CreateProject/NewCreateProjectForm/CreateProjectDialog'; import { useUiFlag } from 'hooks/useUiFlag'; +import { ProjectsListSort } from './ProjectsListSort/ProjectsListSort'; +import { useProjectsListState } from './hooks/useProjectsListState'; +import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; +import { ProjectList as LegacyProjectList } from './LegacyProjectList'; +import { ProjectCreationButton } from './ProjectCreationButton/ProjectCreationButton'; +import { useGroupedProjects } from './hooks/useGroupedProjects'; +import { useProjectsSearchAndSort } from './hooks/useProjectsSearchAndSort'; const StyledApiError = styled(ApiError)(({ theme }) => ({ maxWidth: '500px', @@ -38,148 +30,33 @@ const StyledContainer = styled('div')(({ theme }) => ({ gap: theme.spacing(4), })); -type PageQueryType = Partial>; - -interface ICreateButtonData { - disabled: boolean; - tooltip?: Omit; - 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: ( - - ), - sx: { maxWidth: '320px' }, - variant: 'custom', - }, - endIcon: ( - } - lightmode={} - /> - ), - }; - } 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 ( - <> - setOpenCreateDialog(true)} - maxWidth='700px' - permission={CREATE_PROJECT} - disabled={createButtonData.disabled || loading} - tooltipProps={createButtonData.tooltip} - data-testid={NAVIGATE_TO_CREATE_PROJECT} - > - New project - - setOpenCreateDialog(false)} - /> - > - ); -}; - -export const ProjectListNew = () => { +const NewProjectList = () => { const { projects, loading, error, refetch } = useProjects(); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); - const [searchParams, setSearchParams] = useSearchParams(); - const [searchValue, setSearchValue] = useState( - searchParams.get('search') || '', - ); + + const [state, setState] = useProjectsListState(); const archiveProjectsEnabled = useUiFlag('archiveProjects'); const myProjects = new Set(useProfile().profile?.projects || []); - useEffect(() => { - const tableState: PageQueryType = {}; - if (searchValue) { - tableState.search = searchValue; - } + const setSearchValue = useCallback( + (value: string) => setState({ query: value || undefined }), + [setState], + ); - setSearchParams(tableState, { - replace: true, - }); - }, [searchValue, setSearchParams]); - - const filteredProjects = useMemo(() => { - const regExp = safeRegExp(searchValue, 'i'); - return ( - searchValue - ? projects.filter((project) => regExp.test(project.name)) - : projects - ).sort((a, b) => { - if (a?.favorite && !b?.favorite) { - return -1; - } - if (!a?.favorite && b?.favorite) { - return 1; - } - return 0; - }); - }, [projects, searchValue]); - - const groupedProjects = useMemo(() => { - return groupProjects(myProjects, filteredProjects); - }, [filteredProjects, myProjects]); + const sortedProjects = useProjectsSearchAndSort( + projects, + state.query, + state.sortBy, + ); + const groupedProjects = useGroupedProjects(sortedProjects, myProjects); const projectCount = - filteredProjects.length < projects.length - ? `${filteredProjects.length} of ${projects.length}` + sortedProjects.length < projects.length + ? `${sortedProjects.length} of ${projects.length}` : projects.length; - const ProjectGroupComponent = (props: { - sectionTitle?: string; - projects: IProjectCard[]; - }) => { - return ( - - ); - }; - return ( { show={ <> @@ -214,8 +91,14 @@ export const ProjectListNew = () => { > } /> - - + + setState({ + create: create ? 'true' : undefined, + }) + } + /> > } > @@ -223,7 +106,7 @@ export const ProjectListNew = () => { condition={isSmallScreen} show={ } @@ -241,16 +124,36 @@ export const ProjectListNew = () => { /> )} /> - + setState({ sortBy: sortBy as typeof state.sortBy }) + } /> + + - + + ); }; + +export const ProjectList: FC = () => { + const projectListImprovementsEnabled = useUiFlag('projectListImprovements'); + + if (projectListImprovementsEnabled) { + return ; + } + + return ; +}; diff --git a/frontend/src/component/project/ProjectList/ProjectsListSort/ProjectsListSort.tsx b/frontend/src/component/project/ProjectList/ProjectsListSort/ProjectsListSort.tsx new file mode 100644 index 0000000000..daafbe0dbb --- /dev/null +++ b/frontend/src/component/project/ProjectList/ProjectsListSort/ProjectsListSort.tsx @@ -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 = ({ + sortBy, + setSortBy, +}) => { + return ( + + + + + + ); +}; diff --git a/frontend/src/component/project/ProjectList/hooks/useGroupedProjects.ts b/frontend/src/component/project/ProjectList/hooks/useGroupedProjects.ts new file mode 100644 index 0000000000..62f7900577 --- /dev/null +++ b/frontend/src/component/project/ProjectList/hooks/useGroupedProjects.ts @@ -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, +) => + useMemo( + () => groupProjects(myProjects, filteredAndSortedProjects), + [filteredAndSortedProjects, myProjects], + ); diff --git a/frontend/src/component/project/ProjectList/hooks/useProjectsListState.ts b/frontend/src/component/project/ProjectList/hooks/useProjectsListState.ts new file mode 100644 index 0000000000..5573693978 --- /dev/null +++ b/frontend/src/component/project/ProjectList/hooks/useProjectsListState.ts @@ -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']); diff --git a/frontend/src/component/project/ProjectList/hooks/useProjectsSearchAndSort.test.ts b/frontend/src/component/project/ProjectList/hooks/useProjectsSearchAndSort.test.ts new file mode 100644 index 0000000000..5e4b6854b9 --- /dev/null +++ b/frontend/src/component/project/ProjectList/hooks/useProjectsSearchAndSort.test.ts @@ -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', + ]); + }); +}); diff --git a/frontend/src/component/project/ProjectList/hooks/useProjectsSearchAndSort.ts b/frontend/src/component/project/ProjectList/hooks/useProjectsSearchAndSort.ts new file mode 100644 index 0000000000..fd5ccbdc19 --- /dev/null +++ b/frontend/src/component/project/ProjectList/hooks/useProjectsSearchAndSort.ts @@ -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]); diff --git a/frontend/src/hooks/usePersistentTableState.test.tsx b/frontend/src/hooks/usePersistentTableState.test.tsx index ef0d98af41..ebc7d7b055 100644 --- a/frontend/src/hooks/usePersistentTableState.test.tsx +++ b/frontend/src/hooks/usePersistentTableState.test.tsx @@ -9,12 +9,18 @@ import { FilterItemParam } from '../utils/serializeQueryParams'; type TestComponentProps = { keyName: string; queryParamsDefinition: Record; + nonPersistentParams?: string[]; }; -function TestComponent({ keyName, queryParamsDefinition }: TestComponentProps) { +function TestComponent({ + keyName, + queryParamsDefinition, + nonPersistentParams, +}: TestComponentProps) { const [tableState, setTableState] = usePersistentTableState( keyName, queryParamsDefinition, + nonPersistentParams, ); return ( diff --git a/frontend/src/hooks/usePersistentTableState.ts b/frontend/src/hooks/usePersistentTableState.ts index cae761a39c..fb402419b1 100644 --- a/frontend/src/hooks/usePersistentTableState.ts +++ b/frontend/src/hooks/usePersistentTableState.ts @@ -34,6 +34,7 @@ const usePersistentSearchParams = ( export const usePersistentTableState = ( key: string, queryParamsDefinition: T, + excludedFromStorage: string[] = ['offset'], ) => { const updateStoredParams = usePersistentSearchParams( key, @@ -83,8 +84,12 @@ export const usePersistentTableState = ( ); useEffect(() => { - const { offset, ...rest } = orderedTableState; - updateStoredParams(rest); + const filteredTableState = Object.fromEntries( + Object.entries(orderedTableState).filter( + ([key]) => !excludedFromStorage.includes(key), + ), + ); + updateStoredParams(filteredTableState); }, [JSON.stringify(orderedTableState)]); return [orderedTableState, setTableState] as const; diff --git a/frontend/src/interfaces/project.ts b/frontend/src/interfaces/project.ts index dc04874446..6f250fd100 100644 --- a/frontend/src/interfaces/project.ts +++ b/frontend/src/interfaces/project.ts @@ -6,7 +6,7 @@ import type { ProjectMode } from 'component/project/Project/hooks/useProjectEnte export interface IProjectCard { name: string; id: string; - createdAt: string; + createdAt: string | Date; health?: number; description?: string; featureCount?: number; @@ -15,8 +15,8 @@ export interface IProjectCard { onHover?: () => void; favorite?: boolean; owners?: ProjectSchema['owners']; - lastUpdatedAt?: Date; - lastReportedFlagUsage?: Date; + lastUpdatedAt?: Date | string; + lastReportedFlagUsage?: Date | string; } export type FeatureNamingType = {