From 9c7f1e87df2dd128585127464db44c81d7511dcc Mon Sep 17 00:00:00 2001 From: Pradip Thapa Date: Tue, 8 Oct 2024 11:20:06 +0545 Subject: [PATCH] Feat: updated pagination on list projects (#264) * feat: updated pagination on list projects * refactor(project): make fetching query dyanamic so the querykey, params can be pass dynamically * refactor(project): update project list fetching service params * feat: add usePagination hook * feat: add Pagination component * refactor(dashboard-map-section): remove api call and get project list through props * feat(project-dashboard): implement pagination on project list * feat: update searchInput component * feat(project-dashboard): implement project search * feat(project-dashboard): make responsive header * fix: added new pydentic model class for read the single project --------- Co-authored-by: Sujit --- src/backend/app/projects/project_routes.py | 33 +++-- src/backend/app/projects/project_schemas.py | 49 +++++++- src/frontend/src/api/projects.ts | 10 +- .../components/Projects/MapSection/index.tsx | 76 ++++++------ .../components/Projects/Pagination/index.tsx | 117 ++++++++++++++++++ .../Projects/ProjectsHeader/index.tsx | 75 +++++++---- .../common/FormUI/SearchInput/index.tsx | 2 +- src/frontend/src/constants/index.ts | 8 ++ src/frontend/src/hooks/usePagination.tsx | 93 ++++++++++++++ src/frontend/src/services/createproject.ts | 4 +- src/frontend/src/store/slices/common.ts | 2 + src/frontend/src/views/Projects/index.tsx | 57 +++++++-- 12 files changed, 434 insertions(+), 92 deletions(-) create mode 100644 src/frontend/src/components/Projects/Pagination/index.tsx create mode 100644 src/frontend/src/hooks/usePagination.tsx diff --git a/src/backend/app/projects/project_routes.py b/src/backend/app/projects/project_routes.py index 24df12f1..8d9d5104 100644 --- a/src/backend/app/projects/project_routes.py +++ b/src/backend/app/projects/project_routes.py @@ -302,7 +302,7 @@ async def generate_presigned_url( ) -@router.get("/", tags=["Projects"], response_model=list[project_schemas.ProjectOut]) +@router.get("/", tags=["Projects"], response_model=project_schemas.ProjectOut) async def read_projects( db: Annotated[Connection, Depends(database.get_db)], user_data: Annotated[AuthUser, Depends(login_required)], @@ -310,26 +310,43 @@ async def read_projects( False, description="Filter projects by authenticated user (creator)" ), search: Optional[str] = Query(None, description="Search projects by name"), - skip: int = 0, - limit: int = 100, + page: int = Query(1, ge=1, description="Page number"), + results_per_page: int = Query( + 20, gt=0, le=100, description="Number of results per page" + ), ): "Get all projects with task count." try: user_id = user_data.id if filter_by_owner else None - projects = await project_schemas.DbProject.all( - db, user_id=user_id, search=search, skip=skip, limit=limit + skip = (page - 1) * results_per_page + projects, total_count = await project_schemas.DbProject.all( + db, user_id=user_id, search=search, skip=skip, limit=results_per_page ) if not projects: - return [] + return { + "results": [], + "pagination": { + "page": page, + "per_page": results_per_page, + "total": total_count, + }, + } - return projects + return { + "results": projects, + "pagination": { + "page": page, + "per_page": results_per_page, + "total": total_count, + }, + } except KeyError as e: raise HTTPException(status_code=HTTPStatus.UNPROCESSABLE_ENTITY) from e @router.get( - "/{project_id}", tags=["Projects"], response_model=project_schemas.ProjectOut + "/{project_id}", tags=["Projects"], response_model=project_schemas.ProjectInfo ) async def read_project( project: Annotated[ diff --git a/src/backend/app/projects/project_schemas.py b/src/backend/app/projects/project_schemas.py index 74eeab32..9d963f57 100644 --- a/src/backend/app/projects/project_schemas.py +++ b/src/backend/app/projects/project_schemas.py @@ -5,7 +5,7 @@ from app.projects import project_logic import geojson from loguru import logger as log -from pydantic import BaseModel, computed_field, Field, model_validator +from pydantic import BaseModel, computed_field, Field, model_validator, root_validator from pydantic.functional_validators import AfterValidator from pydantic.functional_serializers import PlainSerializer from geojson_pydantic import Feature, FeatureCollection, Polygon, Point, MultiPolygon @@ -370,7 +370,19 @@ async def all( }, ) db_projects = await cur.fetchall() - return db_projects + + async with db.cursor() as cur: + await cur.execute( + """ + SELECT COUNT(*) FROM projects p + WHERE (p.author_id = COALESCE(%(user_id)s, p.author_id)) + AND p.name ILIKE %(search)s""", + {"user_id": user_id, "search": search_term}, + ) + + total_count = await cur.fetchone() + + return db_projects, total_count[0] @staticmethod async def create(db: Connection, project: ProjectIn, user_id: str) -> uuid.UUID: @@ -476,8 +488,30 @@ async def delete(db: Connection, project_id: uuid.UUID) -> uuid.UUID: return deleted_project_id[0] -class ProjectOut(BaseModel): - """Base project model.""" +class Pagination(BaseModel): + has_next: bool + has_prev: bool + next_num: Optional[int] + prev_num: Optional[int] + page: int + per_page: int + total: int + + @root_validator(pre=True) + def calculate_pagination(cls, values): + page = values.get("page", 1) + total = values.get("total", 1) + + values["has_next"] = page < total + values["has_prev"] = page > 1 + values["next_num"] = page + 1 if values["has_next"] else None + values["prev_num"] = page - 1 if values["has_prev"] else None + + return values + + +class ProjectInfo(BaseModel): + """Out model for the project endpoint.""" id: uuid.UUID slug: Optional[str] = None @@ -504,6 +538,13 @@ def set_image_url(cls, values): return values +class ProjectOut(BaseModel): + """Base project model.""" + + results: Optional[list[ProjectInfo]] = [] + pagination: Optional[Pagination] = {} + + class PresignedUrlRequest(BaseModel): project_id: uuid.UUID task_id: uuid.UUID diff --git a/src/frontend/src/api/projects.ts b/src/frontend/src/api/projects.ts index 81b50b6c..4f31eac3 100644 --- a/src/frontend/src/api/projects.ts +++ b/src/frontend/src/api/projects.ts @@ -5,12 +5,16 @@ import { getTaskStates } from '@Services/project'; import { getUserProfileInfo } from '@Services/common'; export const useGetProjectsListQuery = ( - projectsFilterByOwner: 'yes' | 'no', queryOptions?: Partial, ) => { return useQuery({ - queryKey: ['projects-list', projectsFilterByOwner], - queryFn: () => getProjectsList(projectsFilterByOwner === 'yes'), + queryKey: queryOptions?.queryKey + ? ['projects-list', ...Object.values(queryOptions?.queryKey || {})] + : ['projects-list'], + queryFn: () => + getProjectsList( + queryOptions?.queryKey ? { ...queryOptions.queryKey } : {}, + ), select: (res: any) => res.data, ...queryOptions, }); diff --git a/src/frontend/src/components/Projects/MapSection/index.tsx b/src/frontend/src/components/Projects/MapSection/index.tsx index 1c39273f..5f69fa87 100644 --- a/src/frontend/src/components/Projects/MapSection/index.tsx +++ b/src/frontend/src/components/Projects/MapSection/index.tsx @@ -1,11 +1,9 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { LngLatBoundsLike, Map } from 'maplibre-gl'; import getBbox from '@turf/bbox'; import centroid from '@turf/centroid'; import { FeatureCollection } from 'geojson'; -import { useGetProjectsListQuery } from '@Api/projects'; -import { useTypedSelector } from '@Store/hooks'; import { useMapLibreGLMap } from '@Components/common/MapLibreComponents'; import AsyncPopup from '@Components/common/MapLibreComponents/AsyncPopup'; import BaseLayerSwitcher from '@Components/common/MapLibreComponents/BaseLayerSwitcher'; @@ -13,10 +11,7 @@ import MapContainer from '@Components/common/MapLibreComponents/MapContainer'; import hasErrorBoundary from '@Utils/hasErrorBoundary'; import VectorLayerWithCluster from './VectorLayerWithCluster'; -const ProjectsMapSection = () => { - const projectsFilterByOwner = useTypedSelector( - state => state.createproject.ProjectsFilterByOwner, - ); +const ProjectsMapSection = ({ projectList }: { projectList: any }) => { const [projectProperties, setProjectProperties] = useState< Record >({}); @@ -30,41 +25,40 @@ const ProjectsMapSection = () => { }, disableRotation: true, }); - const { data: projectsList, isLoading }: Record = - useGetProjectsListQuery(projectsFilterByOwner, { - select: (data: any) => { - // find all polygons centroid and set to geojson save to single geojson - const combinedGeojson = data?.data?.reduce( - (acc: Record, current: Record) => { - return { - ...acc, - features: [ - ...acc.features, - { - ...centroid(current.outline), - properties: { - id: current?.id, - name: current?.name, - slug: current?.slug, - }, - }, - ], - }; - }, - { - type: 'FeatureCollection', - features: [], - }, - ); - return combinedGeojson; + + const projectsGeojson = useMemo(() => { + if (!projectList || !projectList?.length) return []; + // find all polygons centroid and set to geojson save to single geojson + const combinedGeojson = projectList?.reduce( + (acc: Record, current: Record) => { + return { + ...acc, + features: [ + ...acc.features, + { + ...centroid(current.outline), + properties: { + id: current?.id, + name: current?.name, + slug: current?.slug, + }, + }, + ], + }; }, - }); + { + type: 'FeatureCollection', + features: [], + }, + ); + return combinedGeojson; + }, [projectList]); useEffect(() => { - if (!projectsList || !projectsList?.features?.length) return; - const bbox = getBbox(projectsList as FeatureCollection); + if (!projectsGeojson || !projectsGeojson?.features?.length) return; + const bbox = getBbox(projectsGeojson as FeatureCollection); map?.fitBounds(bbox as LngLatBoundsLike, { padding: 100, duration: 500 }); - }, [projectsList, map]); + }, [projectsGeojson, map]); const getPopupUI = useCallback(() => { return ( @@ -87,13 +81,13 @@ const ProjectsMapSection = () => { > - {projectsList && ( + {projectsGeojson && ( )} diff --git a/src/frontend/src/components/Projects/Pagination/index.tsx b/src/frontend/src/components/Projects/Pagination/index.tsx new file mode 100644 index 00000000..e2332b26 --- /dev/null +++ b/src/frontend/src/components/Projects/Pagination/index.tsx @@ -0,0 +1,117 @@ +/* eslint-disable no-nested-ternary */ +import { Button } from '@Components/RadixComponents/Button'; +import { FlexRow } from '@Components/common/Layouts'; +import { Input, Select } from '@Components/common/FormUI'; +import usePagination, { DOTS } from '@Hooks/usePagination'; +import { useMemo } from 'react'; +import { rowsPerPageOptions } from '@Constants/index'; + +interface IPaginationProps { + totalCount: number; + siblingCount?: number; + currentPage: number; + pageSize: number; + handlePaginationState: any; +} + +export default function Pagination({ + totalCount, + siblingCount = 1, + currentPage, + pageSize, + handlePaginationState, +}: IPaginationProps) { + const paginationRange = usePagination({ + currentPage, + totalCount, + siblingCount, + pageSize, + }); + + const lastPage = useMemo( + () => Number(paginationRange[paginationRange.length - 1]), + [paginationRange], + ); + + if (currentPage === 0 || paginationRange.length < 2) { + return null; + } + + return ( + + + +

Row per page

+ { + const page = e.target.value ? Number(e.target.value) : 1; + const validPage = + page >= lastPage ? lastPage : page <= 1 ? 1 : page; + handlePaginationState({ activePage: validPage }); + }} + className="naxatw-w-12 naxatw-border-b-2 naxatw-px-1 naxatw-py-0" + /> +
+
+
+ + + + ); + })} + +