From 1228ec9c815e233d800cc689e944e429c7846ac5 Mon Sep 17 00:00:00 2001 From: Nishit Suwal <81785002+NSUWAL123@users.noreply.github.com> Date: Thu, 28 Dec 2023 06:36:11 +0545 Subject: [PATCH] feat: new project details page (#1070) * fix (select): className added to select * feat (routes): newproject_details route added * feat (assetModules): icons added * feat (newProjectDetails): new UI for projectDetails * feat (newProjectDetails): navigate to specified URL on click * fix (select): classname added * fix (activitiesPanel): bg added * fix (select): show title if present * fix (newProjectDetails): new UI width changes * fix (newProjectDetails): height/width adjusted * feat (assetModules): icons added * feat (newProjectDetails): slicing for taskActivity activities section * feat (newProjectDetails): zoomToTask feature added on mapIcon click * fix (newProjectDetails): desc gaps reduced * feat (newProjectDetails): necessary files added to ProjectDetailsV2 folder and imports updated * feat (newProjectDetails): skeletonLoader added to projectDetails section * feat: added new field to user model to store user profile_img * feat: updated schema to add updated task history * fix (newProjectDetails): taskHistorySection: dynamic value for username, userProfilePic, changedToStatus * fix (newProjectDetails): color adjustment * fix (newProjectDetails): taskHistory - changed taskStatus override issue solved * fix (codeRefactor): comments removed * updated response of update_task_status * feat (newProjectDetails): projectDashboard api fetch * fix (enwProjectDetails): textColor according to status changed * feat (newProjectDetails): taskActivity - container added for sorting task activities * fix (newProjectDetails): organization logo fixes * fix (newProjectDetails): UI fix * fix (themeSlice): statusTextColor - text color changed * fix (newProjectDetails): dynamic location added * fix (projectDetails): added new UI button to redirect to the new project details UI --------- Co-authored-by: sujanadh --- src/frontend/src/api/Project.js | 20 + .../ProjectDetailsV2/ActivitiesPanel.tsx | 208 ++++++++ .../ProjectDetailsV2/MapControlComponent.tsx | 72 +++ .../MobileActivitiesContents.tsx | 27 + .../ProjectDetailsV2/MobileFooter.tsx | 106 ++++ .../MobileProjectInfoContent.tsx | 30 ++ .../ProjectDetailsV2/ProjectInfo.tsx | 103 ++++ .../ProjectDetailsV2/ProjectOptions.tsx | 138 +++++ .../ProjectDetailsV2/SkeletonLoader.tsx | 35 ++ .../ProjectDetailsV2/TaskSectionPopup.tsx | 34 ++ src/frontend/src/components/common/Select.tsx | 8 +- src/frontend/src/routes.jsx | 13 + src/frontend/src/shared/AssetModules.js | 14 + src/frontend/src/store/slices/ProjectSlice.ts | 12 + src/frontend/src/store/slices/ThemeSlice.ts | 8 + src/frontend/src/views/NewProjectDetails.jsx | 8 +- src/frontend/src/views/ProjectDetailsV2.tsx | 502 ++++++++++++++++++ 17 files changed, 1334 insertions(+), 4 deletions(-) create mode 100644 src/frontend/src/components/ProjectDetailsV2/ActivitiesPanel.tsx create mode 100644 src/frontend/src/components/ProjectDetailsV2/MapControlComponent.tsx create mode 100644 src/frontend/src/components/ProjectDetailsV2/MobileActivitiesContents.tsx create mode 100644 src/frontend/src/components/ProjectDetailsV2/MobileFooter.tsx create mode 100644 src/frontend/src/components/ProjectDetailsV2/MobileProjectInfoContent.tsx create mode 100644 src/frontend/src/components/ProjectDetailsV2/ProjectInfo.tsx create mode 100644 src/frontend/src/components/ProjectDetailsV2/ProjectOptions.tsx create mode 100644 src/frontend/src/components/ProjectDetailsV2/SkeletonLoader.tsx create mode 100644 src/frontend/src/components/ProjectDetailsV2/TaskSectionPopup.tsx create mode 100644 src/frontend/src/views/ProjectDetailsV2.tsx diff --git a/src/frontend/src/api/Project.js b/src/frontend/src/api/Project.js index 4fdc543aed..9dac7b9a93 100755 --- a/src/frontend/src/api/Project.js +++ b/src/frontend/src/api/Project.js @@ -6,6 +6,7 @@ export const ProjectById = (existingProjectList, projectId) => { return async (dispatch) => { const fetchProjectById = async (projectId, existingProjectList) => { try { + dispatch(ProjectActions.SetProjectDetialsLoading(true)); const project = await CoreModules.axios.get(`${import.meta.env.VITE_API_URL}/projects/${projectId}`); const taskList = await CoreModules.axios.get( `${import.meta.env.VITE_API_URL}/tasks/task-list?project_id=${projectId}`, @@ -57,6 +58,7 @@ export const ProjectById = (existingProjectList, projectId) => { tasks_bad: projectResp.tasks_bad, }), ); + dispatch(ProjectActions.SetProjectDetialsLoading(false)); } catch (error) { // console.log('error :', error) } @@ -184,3 +186,21 @@ export const DownloadTile = (url, payload) => { await getDownloadTile(url, payload); }; }; + +export const GetProjectDashboard = (url) => { + return async (dispatch) => { + const getProjectDashboard = async (url) => { + try { + dispatch(ProjectActions.SetProjectDashboardLoading(true)); + const response = await CoreModules.axios.get(url); + dispatch(ProjectActions.SetProjectDashboardDetail(response.data)); + dispatch(ProjectActions.SetProjectDashboardLoading(false)); + } catch (error) { + dispatch(ProjectActions.SetProjectDashboardLoading(false)); + } finally { + dispatch(ProjectActions.SetProjectDashboardLoading(false)); + } + }; + await getProjectDashboard(url); + }; +}; diff --git a/src/frontend/src/components/ProjectDetailsV2/ActivitiesPanel.tsx b/src/frontend/src/components/ProjectDetailsV2/ActivitiesPanel.tsx new file mode 100644 index 0000000000..fcd4a4869e --- /dev/null +++ b/src/frontend/src/components/ProjectDetailsV2/ActivitiesPanel.tsx @@ -0,0 +1,208 @@ +/* eslint-disable react/jsx-key */ +import React, { useEffect, useState } from 'react'; +import environment from '../../environment'; +import CoreModules from '../../shared/CoreModules'; +import AssetModules from '../../shared/AssetModules'; +import { CustomSelect } from '../../components/common/Select'; +import profilePic from '../../assets/images/project_icon.png'; +import { Feature } from 'ol'; +import { Polygon } from 'ol/geom'; +import { ActivitiesCardSkeletonLoader, ShowingCountSkeletonLoader } from './SkeletonLoader'; + +const sortByList = [ + { id: 'activities', name: 'Activities' }, + { id: 'date', name: 'Date' }, + { id: 'users', name: 'Users' }, + { id: 'taskid', name: 'Task ID' }, +]; + +const ActivitiesPanel = ({ defaultTheme, state, params, map, view, mapDivPostion, states }) => { + const displayLimit = 10; + const [searchText, setSearchText] = useState(''); + const [taskHistories, setTaskHistories] = useState([]); + const [taskDisplay, setTaskDisplay] = React.useState(displayLimit); + const [allActivities, setAllActivities] = useState(0); + const [sortBy, setSortBy] = useState(null); + const [showShortBy, setShowSortBy] = useState(false); + const projectDetailsLoading = CoreModules.useAppSelector((state) => state?.project?.projectDetailsLoading); + + const handleOnchange = (event) => { + setSearchText(event.target.value); + }; + + useEffect(() => { + const index = state.findIndex((project) => project.id == environment.decode(params.id)); + let taskHistories = []; + + if (index != -1) { + state[index].taskBoundries.forEach((task) => { + taskHistories = taskHistories.concat( + task.task_history.map((history) => { + return { + ...history, + changedToStatus: history.status, + taskId: task.id, + status: task.task_status, + outlineGeojson: task.outline_geojson, + }; + }), + ); + }); + } + setAllActivities(taskHistories.length); + + let finalTaskHistory = taskHistories.filter((task) => { + return ( + task.taskId.toString().includes(searchText) || + task.action_text.split(':')[1].replace(/\s+/g, '').toString().includes(searchText.toString()) + ); + }); + + if (searchText != '') { + setTaskHistories(finalTaskHistory); + } else { + setTaskHistories(taskHistories); + } + }, [taskDisplay, state, searchText]); + + const zoomToTask = (taskId) => { + const geojson = taskHistories + .filter((history) => history.taskId === taskId) + .map((history) => history.outlineGeojson)[0]; + + const olFeature = new Feature({ + geometry: new Polygon(geojson.geometry.coordinates).transform('EPSG:4326', 'EPSG:3857'), + }); + // Get the extent of the OpenLayers feature + const extent = olFeature.getGeometry().getExtent(); + map.getView().fit(extent, { + padding: [0, 0, 0, 0], + }); + }; + + const ActivitiesCard = ({ taskHistory }) => { + const actionDate = taskHistory?.action_date?.split('T')[0]; + const actionTime = `${taskHistory?.action_date?.split('T')[1].split(':')[0]}:${taskHistory?.action_date + ?.split('T')[1] + .split(':')[1]}`; + return ( +
+
+
+ {taskHistory?.profile_img ? ( + Profile Picture + ) : ( +
+ +
+ )} +
+
+ {taskHistory?.username} + + updated status to{' '} + +

+ {taskHistory?.changedToStatus} +

+
+

#{taskHistory.taskId}

+
+ +
+

+ {actionDate} + {actionTime} +

+
+
+
+
zoomToTask(taskHistory.taskId)}> + +
+
+ ); + }; + + return ( +
+
+
+ +
+
+
setShowSortBy(!showShortBy)} + > + +

Sort

+
+ {showShortBy && ( +
+ {/*

Sort By:

*/} + {sortByList.map((item, i) => ( +
i && + 'fmtm-border-b-[1px] fmtm-border-b-slate-200 sm:fmtm-border-gray-100' + }`} + onClick={() => { + if (item.name === sortBy) { + setSortBy(null); + } else { + setSortBy(item.name); + } + setShowSortBy(false); + }} + > + {/* {sortBy === item.name &&} */} + +
{item.name}
+
+ ))} +
+ )} +
+
+
+ {projectDetailsLoading ? ( + + ) : ( +

+ showing {taskHistories?.length} of {allActivities} activities +

+ )} +
+
+ {projectDetailsLoading ? ( +
+ {Array.from({ length: 10 }).map((i) => ( + + ))} +
+ ) : ( +
{taskHistories?.map((taskHistory) => )}
+ )} +
+
+ ); +}; + +export default ActivitiesPanel; diff --git a/src/frontend/src/components/ProjectDetailsV2/MapControlComponent.tsx b/src/frontend/src/components/ProjectDetailsV2/MapControlComponent.tsx new file mode 100644 index 0000000000..a0ab6ea5fd --- /dev/null +++ b/src/frontend/src/components/ProjectDetailsV2/MapControlComponent.tsx @@ -0,0 +1,72 @@ +import React, { useState } from 'react'; +import AssetModules from '../../shared/AssetModules'; +import VectorLayer from 'ol/layer/Vector'; +import CoreModules from '../../shared/CoreModules.js'; +import { ProjectActions } from '../../store/slices/ProjectSlice'; + +const MapControlComponent = ({ map }) => { + const btnList = [ + { + id: 'add', + icon: , + }, + { + id: 'minus', + icon: , + }, + { + id: 'currentLocation', + icon: , + }, + { + id: 'taskBoundries', + icon: , + }, + ]; + const dispatch = CoreModules.useAppDispatch(); + const [toggleCurrentLoc, setToggleCurrentLoc] = useState(false); + const geolocationStatus = CoreModules.useAppSelector((state) => state.project.geolocationStatus); + const handleOnClick = (btnId) => { + if (btnId === 'add') { + const actualZoom = map.getView().getZoom(); + map.getView().setZoom(actualZoom + 1); + } else if (btnId === 'minus') { + const actualZoom = map.getView().getZoom(); + map.getView().setZoom(actualZoom - 1); + } else if (btnId === 'currentLocation') { + setToggleCurrentLoc(!toggleCurrentLoc); + dispatch(ProjectActions.ToggleGeolocationStatus(!geolocationStatus)); + } else if (btnId === 'taskBoundries') { + const layers = map.getAllLayers(); + let extent; + layers.map((layer) => { + if (layer instanceof VectorLayer) { + const layerName = layer.getProperties().name; + if (layerName === 'project-area') { + extent = layer.getSource().getExtent(); + } + } + }); + map.getView().fit(extent, { + padding: [10, 10, 10, 10], + }); + } + }; + + return ( +
+ {btnList.map((btn) => ( +
+
handleOnClick(btn.id)} + > + {btn.icon} +
+
+ ))} +
+ ); +}; + +export default MapControlComponent; diff --git a/src/frontend/src/components/ProjectDetailsV2/MobileActivitiesContents.tsx b/src/frontend/src/components/ProjectDetailsV2/MobileActivitiesContents.tsx new file mode 100644 index 0000000000..177a28585f --- /dev/null +++ b/src/frontend/src/components/ProjectDetailsV2/MobileActivitiesContents.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import ActivitiesPanel from './ActivitiesPanel'; +import CoreModules from '../../shared/CoreModules'; + +const MobileActivitiesContents = ({ map, mainView, mapDivPostion }) => { + const params = CoreModules.useParams(); + const state = CoreModules.useAppSelector((state) => state.project); + const defaultTheme = CoreModules.useAppSelector((state) => state.theme.hotTheme); + + return ( +
+ + + +
+ ); +}; + +export default MobileActivitiesContents; diff --git a/src/frontend/src/components/ProjectDetailsV2/MobileFooter.tsx b/src/frontend/src/components/ProjectDetailsV2/MobileFooter.tsx new file mode 100644 index 0000000000..cab3cc8ca0 --- /dev/null +++ b/src/frontend/src/components/ProjectDetailsV2/MobileFooter.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import AssetModules from '../../shared/AssetModules.js'; +import CoreModules from '../../shared/CoreModules'; +import { ProjectActions } from '../../store/slices/ProjectSlice'; + +const MobileFooter = () => { + const dispatch = CoreModules.useAppDispatch(); + const mobileFooterSelection = CoreModules.useAppSelector((state) => state.project.mobileFooterSelection); + + const footerItem = [ + { + id: 'explore', + title: 'Explore', + icon: ( + + ), + }, + { + id: 'projectInfo', + title: 'Project Info', + icon: ( + + ), + }, + { + id: 'activities', + title: 'Activities', + icon: ( + + ), + }, + { + id: 'mapLegend', + title: 'Legend', + icon: ( + + ), + }, + , + { + id: 'others', + title: 'Others', + icon: ( + + ), + }, + ]; + const FooterItemList = ({ item }) => { + return ( +
dispatch(ProjectActions.SetMobileFooterSelection(item?.id))} + className="fmtm-group fmtm-cursor-pointer" + > +
+
{item?.icon}
+
+
+

+ {item?.title} +

+
+
+ ); + }; + return ( +
+
+ {footerItem.map((item) => ( + + ))} +
+
+ ); +}; + +export default MobileFooter; diff --git a/src/frontend/src/components/ProjectDetailsV2/MobileProjectInfoContent.tsx b/src/frontend/src/components/ProjectDetailsV2/MobileProjectInfoContent.tsx new file mode 100644 index 0000000000..ff15c16a12 --- /dev/null +++ b/src/frontend/src/components/ProjectDetailsV2/MobileProjectInfoContent.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import AssetModules from '../../shared/AssetModules'; + +const MobileProjectInfoContent = ({ projectInfo }) => { + return ( +
+
+ +

Project Information

+
+
+

#{projectInfo?.id}

+
+
+

Name:

+

{projectInfo?.title}

+
+
+

Short Description:

+

{projectInfo?.short_description}

+
+
+

Description:

+

{projectInfo?.description}

+
+
+ ); +}; + +export default MobileProjectInfoContent; diff --git a/src/frontend/src/components/ProjectDetailsV2/ProjectInfo.tsx b/src/frontend/src/components/ProjectDetailsV2/ProjectInfo.tsx new file mode 100644 index 0000000000..d3e22756cd --- /dev/null +++ b/src/frontend/src/components/ProjectDetailsV2/ProjectInfo.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useRef, useState } from 'react'; +import AssetModules from '../../shared/AssetModules.js'; +import ProjectIcon from '../../assets/images/project_icon.png'; +import CoreModules from '../../shared/CoreModules'; + +const ProjectInfo = () => { + const paraRef = useRef(null); + const [seeMore, setSeeMore] = useState(false); + const [descLines, setDescLines] = useState(1); + const projectInfo = CoreModules.useAppSelector((state) => state?.project?.projectInfo); + const projectDetailsLoading = CoreModules.useAppSelector((state) => state?.project?.projectDetailsLoading); + const projectDashboardDetail = CoreModules.useAppSelector((state) => state?.project?.projectDashboardDetail); + const projectDashboardLoading = CoreModules.useAppSelector((state) => state?.project?.projectDashboardLoading); + + useEffect(() => { + if (paraRef.current) { + const lineHeight = parseFloat(getComputedStyle(paraRef.current).lineHeight); + const lines = Math.floor(paraRef.current.clientHeight / lineHeight); + setDescLines(lines); + } + }, [projectInfo, paraRef.current]); + + return ( +
+
+

Description

+ {projectDetailsLoading ? ( +
+ {Array.from({ length: 10 }).map((i) => ( + + ))} + +
+ ) : ( +
+

+ {projectInfo?.description} +

+ {descLines >= 10 && ( +

setSeeMore(!seeMore)} + > + ... {!seeMore ? 'See More' : 'See Less'} +

+ )} +
+ )} +
+
+ + {projectDetailsLoading ? ( + + ) : ( +

{projectInfo?.location_str ? projectInfo?.location_str : '-'}

+ )} +
+
+
+

Contributors

+ {projectDashboardLoading ? ( + + ) : ( +

{projectDashboardDetail?.total_contributors}

+ )} +
+
+

Last Contribution

+ {projectDashboardLoading ? ( + + ) : ( +

+ {projectDashboardDetail?.last_active ? projectDashboardDetail?.last_active : '-'} +

+ )} +
+
+
+

Organized By:

+ {projectDashboardLoading ? ( +
+ + +
+ ) : ( +
+
+ Organization Photo +
+

{projectDashboardDetail?.organization}

+
+ )} +
+
+ ); +}; + +export default ProjectInfo; diff --git a/src/frontend/src/components/ProjectDetailsV2/ProjectOptions.tsx b/src/frontend/src/components/ProjectDetailsV2/ProjectOptions.tsx new file mode 100644 index 0000000000..66ad5e5074 --- /dev/null +++ b/src/frontend/src/components/ProjectDetailsV2/ProjectOptions.tsx @@ -0,0 +1,138 @@ +import React, { useState } from 'react'; +import CoreModules from '../../shared/CoreModules'; +import AssetModules from '../../shared/AssetModules'; +import environment from '../../environment'; +import { DownloadDataExtract, DownloadProjectForm } from '../../api/Project'; +import { ProjectActions } from '../../store/slices/ProjectSlice'; + +const ProjectOptions = ({ setToggleGenerateModal }) => { + const dispatch = CoreModules.useAppDispatch(); + const params = CoreModules.useParams(); + + const [toggleAction, setToggleAction] = useState(false); + const downloadProjectFormLoading = CoreModules.useAppSelector((state) => state.project.downloadProjectFormLoading); + const downloadDataExtractLoading = CoreModules.useAppSelector((state) => state.project.downloadDataExtractLoading); + + const encodedId = params.id; + const decodedId = environment.decode(encodedId); + + const handleDownload = (downloadType) => { + if (downloadType === 'form') { + dispatch( + DownloadProjectForm( + `${import.meta.env.VITE_API_URL}/projects/download_form/${decodedId}/`, + downloadType, + decodedId, + ), + ); + } else if (downloadType === 'geojson') { + dispatch( + DownloadProjectForm( + `${import.meta.env.VITE_API_URL}/projects/${decodedId}/download_tasks`, + downloadType, + decodedId, + ), + ); + } + }; + const onDataExtractDownload = () => { + dispatch( + DownloadDataExtract(`${import.meta.env.VITE_API_URL}/projects/features/download/?project_id=${decodedId}`), + ); + }; + return ( +
+
+ +

Project Options

+
+
+
+ handleDownload('form')} + sx={{ width: 'unset' }} + loading={downloadProjectFormLoading.type === 'form' && downloadProjectFormLoading.loading} + loadingPosition="end" + endIcon={} + variant="contained" + color="error" + > + Form + + handleDownload('geojson')} + sx={{ width: 'unset' }} + loading={downloadProjectFormLoading.type === 'geojson' && downloadProjectFormLoading.loading} + loadingPosition="end" + endIcon={} + variant="contained" + color="error" + > + Tasks + + onDataExtractDownload()} + sx={{ width: 'unset' }} + loading={downloadDataExtractLoading} + loadingPosition="end" + endIcon={} + variant="contained" + color="error" + className="fmtm-truncate" + > + Data Extract + +
+
+ + + ProjectInfo + + + { + setToggleGenerateModal(true); + dispatch(ProjectActions.SetMobileFooterSelection('explore')); + }} + variant="contained" + color="error" + sx={{ width: '200px', mr: '15px' }} + endIcon={} + className="fmtm-truncate" + > + Generate MbTiles + + + + Edit Project + + +
+
+
+ ); +}; + +export default ProjectOptions; diff --git a/src/frontend/src/components/ProjectDetailsV2/SkeletonLoader.tsx b/src/frontend/src/components/ProjectDetailsV2/SkeletonLoader.tsx new file mode 100644 index 0000000000..629102c5aa --- /dev/null +++ b/src/frontend/src/components/ProjectDetailsV2/SkeletonLoader.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import CoreModules from '../../shared/CoreModules'; + +export const ActivitiesCardSkeletonLoader = () => { + return ( +
+ +
+ + +
+
+ +
+
+ +
+
+
+ +
+ ); +}; + +export const ShowingCountSkeletonLoader = () => { + return ( +
+ + + + + +
+ ); +}; diff --git a/src/frontend/src/components/ProjectDetailsV2/TaskSectionPopup.tsx b/src/frontend/src/components/ProjectDetailsV2/TaskSectionPopup.tsx new file mode 100644 index 0000000000..2f91da89f9 --- /dev/null +++ b/src/frontend/src/components/ProjectDetailsV2/TaskSectionPopup.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import CoreModules from '../../shared/CoreModules'; +import AssetModules from '../../shared/AssetModules'; +import { ProjectActions } from '../../store/slices/ProjectSlice'; + +const TaskSectionPopup = ({ body }) => { + const dispatch = CoreModules.useAppDispatch(); + const taskModalStatus = CoreModules.useAppSelector((state) => state.project.taskModalStatus); + + return ( +
+
dispatch(ProjectActions.ToggleTaskModalStatus(false))} + className={`fmtm-absolute fmtm-top-[17px] fmtm-right-[20px] ${ + taskModalStatus ? '' : 'fmtm-hidden' + } fmtm-cursor-pointer`} + > + +
+
+ {body} +
+
+ ); +}; + +export default TaskSectionPopup; diff --git a/src/frontend/src/components/common/Select.tsx b/src/frontend/src/components/common/Select.tsx index 7a325dfbe5..1affd68046 100644 --- a/src/frontend/src/components/common/Select.tsx +++ b/src/frontend/src/components/common/Select.tsx @@ -117,6 +117,7 @@ interface ICustomSelect { label: string; onValueChange: (value: string | null | number) => void; errorMsg: string; + className: string; } export const CustomSelect = ({ @@ -129,12 +130,13 @@ export const CustomSelect = ({ label, onValueChange, errorMsg, + className, }: ICustomSelect) => { return ( -
-

{title}

+
+ {title &&

{title}

}
-
+