Skip to content

Commit

Permalink
Feat: updated pagination on list projects (#264)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
Pradip-p and Sujit authored Oct 8, 2024
1 parent 3ba1c73 commit 9c7f1e8
Show file tree
Hide file tree
Showing 12 changed files with 434 additions and 92 deletions.
33 changes: 25 additions & 8 deletions src/backend/app/projects/project_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,34 +302,51 @@ 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)],
filter_by_owner: Optional[bool] = Query(
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[
Expand Down
49 changes: 45 additions & 4 deletions src/backend/app/projects/project_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 7 additions & 3 deletions src/frontend/src/api/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ import { getTaskStates } from '@Services/project';
import { getUserProfileInfo } from '@Services/common';

export const useGetProjectsListQuery = (
projectsFilterByOwner: 'yes' | 'no',
queryOptions?: Partial<UseQueryOptions>,
) => {
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,
});
Expand Down
76 changes: 35 additions & 41 deletions src/frontend/src/components/Projects/MapSection/index.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,17 @@
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';
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<string, any>
>({});
Expand All @@ -30,41 +25,40 @@ const ProjectsMapSection = () => {
},
disableRotation: true,
});
const { data: projectsList, isLoading }: Record<string, any> =
useGetProjectsListQuery(projectsFilterByOwner, {
select: (data: any) => {
// find all polygons centroid and set to geojson save to single geojson
const combinedGeojson = data?.data?.reduce(
(acc: Record<string, any>, current: Record<string, any>) => {
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<string, any>, current: Record<string, any>) => {
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 (
Expand All @@ -87,13 +81,13 @@ const ProjectsMapSection = () => {
>
<BaseLayerSwitcher />

{projectsList && (
{projectsGeojson && (
<VectorLayerWithCluster
map={map}
visibleOnMap={!isLoading}
visibleOnMap
mapLoaded={isMapLoaded}
sourceId="clustered-projects"
geojson={projectsList}
geojson={projectsGeojson}
/>
)}

Expand Down
117 changes: 117 additions & 0 deletions src/frontend/src/components/Projects/Pagination/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<FlexRow className="naxatw-fixed naxatw-bottom-0 naxatw-left-0 naxatw-right-0 naxatw-w-full naxatw-flex-col naxatw-items-center naxatw-justify-between naxatw-gap-4 naxatw-bg-white naxatw-px-3 naxatw-py-2.5 md:naxatw-absolute md:naxatw-flex md:naxatw-flex-row md:naxatw-gap-0 lg:naxatw-px-16">
<FlexRow className="naxatw-w-full naxatw-items-center naxatw-justify-between naxatw-gap-2 md:naxatw-w-[78%]">
<FlexRow gap={4} className="naxatw-items-center">
<p className="naxatw-text-sm naxatw-font-bold">Row per page</p>
<Select
options={rowsPerPageOptions}
onChange={value =>
handlePaginationState({
selectedNumberOfRows: value,
activePage: 1,
})
}
selectedOption={pageSize}
labelKey="label"
valueKey="value"
placeholder="Select"
direction="top"
className="naxatw-h-9 !naxatw-w-[64px] naxatw-rounded-lg naxatw-border md:!naxatw-w-20"
/>
</FlexRow>
<FlexRow gap={2}>
<FlexRow className="naxatw-items-center naxatw-gap-4">
<p className="naxatw-text-sm naxatw-font-bold">Go to page</p>
<Input
type="number"
defaultValue={currentPage}
min={1}
onChange={e => {
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"
/>
</FlexRow>
</FlexRow>
</FlexRow>

<FlexRow className="naxatw-items-center">
<Button
size="sm"
className="!naxatw-text-gray-700"
leftIcon=" chevron_left"
onClick={() => handlePaginationState({ activePage: currentPage - 1 })}
disabled={currentPage <= 1}
/>
<FlexRow className="naxatw-items-center naxatw-justify-center naxatw-gap-3">
{paginationRange.map(pageNumber => {
if (pageNumber === DOTS) {
return <span key={pageNumber}>&#8230;</span>;
}
return (
<Button
size="sm"
key={pageNumber}
className={`!naxatw-text-gray-500 naxatw-no-underline ${currentPage === pageNumber ? 'naxatw-rounded-b-none naxatw-border-b-2 naxatw-border-gray-800 !naxatw-text-gray-800' : ''}`}
onClick={() =>
handlePaginationState({ activePage: pageNumber })
}
>
{pageNumber}
</Button>
);
})}
</FlexRow>
<Button
size="sm"
className="!naxatw-text-gray-700"
disabled={Number(currentPage) >= lastPage}
onClick={() => handlePaginationState({ activePage: currentPage + 1 })}
rightIcon="chevron_right"
/>
</FlexRow>
</FlexRow>
);
}
Loading

0 comments on commit 9c7f1e8

Please sign in to comment.