diff --git a/src/backend/functions/work-packages.ts b/src/backend/functions/work-packages.ts index 50bd413e..31542694 100644 --- a/src/backend/functions/work-packages.ts +++ b/src/backend/functions/work-packages.ts @@ -39,7 +39,7 @@ const wpQueryArgs = Prisma.validator()({ include: { projectLead: true, projectManager: true, - changes: { include: { implementer: true } } + changes: { include: { implementer: true }, orderBy: { dateImplemented: 'asc' } } } }, expectedActivities: true, @@ -104,18 +104,22 @@ const workPackageTransformer = (wpInput: Prisma.Work_PackageGetPayload { const { queryStringParameters: eQSP } = event; const workPackages = await prisma.work_Package.findMany(wpQueryArgs); - return buildSuccessResponse( - workPackages.map(workPackageTransformer).filter((wp) => { - let passes = true; - if (eQSP?.status) passes &&= wp.status === eQSP?.status; - if (eQSP?.timelineStatus) passes &&= wp.timelineStatus === eQSP?.timelineStatus; - return passes; - }) - ); + const outputWorkPackages = workPackages.map(workPackageTransformer).filter((wp) => { + let passes = true; + if (eQSP?.status) passes &&= wp.status === eQSP?.status; + if (eQSP?.timelineStatus) passes &&= wp.timelineStatus === eQSP?.timelineStatus; + if (eQSP?.daysUntilDeadline) { + const daysToDeadline = Math.round((wp.endDate.getTime() - new Date().getTime()) / 86400000); + passes &&= daysToDeadline <= parseInt(eQSP?.daysUntilDeadline); + } + return passes; + }); + outputWorkPackages.sort((wpA, wpB) => wpA.endDate.getTime() - wpB.endDate.getTime()); + return buildSuccessResponse(outputWorkPackages); }; // Fetch the work package for the specified WBS number @@ -138,27 +142,6 @@ const getSingleWorkPackage: ApiRouteFunction = async (params: { wbsNum: string } return buildSuccessResponse(workPackageTransformer(wp)); }; -// Fetch all work packages with an end date in the next 2 weeks -const getAllWorkPackagesUpcomingDeadlines: ApiRouteFunction = async () => { - const workPackages = await prisma.work_Package.findMany({ - where: { - wbsElement: { - status: WBS_Element_Status.ACTIVE - } - }, - ...wpQueryArgs - }); - const outputWorkPackages = workPackages - .filter((wp) => { - const endDate = calculateEndDate(wp.startDate, wp.duration); - const daysFromNow = Math.round((endDate.getTime() - new Date().getTime()) / 86400000); - return daysFromNow <= 14; - }) - .map(workPackageTransformer); - outputWorkPackages.sort((wpA, wpB) => wpA.endDate.getTime() - wpB.endDate.getTime()); - return buildSuccessResponse(outputWorkPackages); -}; - // Define all valid routes for the endpoint const routes: ApiRoute[] = [ { @@ -166,11 +149,6 @@ const routes: ApiRoute[] = [ httpMethod: 'GET', func: getAllWorkPackages }, - { - path: `${API_URL}${apiRoutes.WORK_PACKAGES_UPCOMING_DEADLINES}`, - httpMethod: 'GET', - func: getAllWorkPackagesUpcomingDeadlines - }, { path: `${API_URL}${apiRoutes.WORK_PACKAGES_BY_WBS}`, httpMethod: 'GET', diff --git a/src/frontend/layouts/nav-top-bar/nav-top-bar.tsx b/src/frontend/layouts/nav-top-bar/nav-top-bar.tsx index ee8f04a6..93b61f03 100644 --- a/src/frontend/layouts/nav-top-bar/nav-top-bar.tsx +++ b/src/frontend/layouts/nav-top-bar/nav-top-bar.tsx @@ -6,13 +6,13 @@ import { Dropdown, Nav, Navbar } from 'react-bootstrap'; import { Link } from 'react-router-dom'; import { routes } from '../../../shared/routes'; -import NavUserMenu from './nav-user-menu/nav-user-menu'; -import NavNotificationsMenu from './nav-notifications-menu/nav-notifications-menu'; -import styles from './nav-top-bar.module.css'; import { useAuth } from '../../../services/auth.hooks'; import { fullNamePipe } from '../../../shared/pipes'; import { useTheme } from '../../../services/theme.hooks'; import themes from '../../../shared/themes'; +import NavUserMenu from './nav-user-menu/nav-user-menu'; +import NavNotificationsMenu from './nav-notifications-menu/nav-notifications-menu'; +import styles from './nav-top-bar.module.css'; const NavTopBar: React.FC = () => { const auth = useAuth(); @@ -39,7 +39,9 @@ const NavTopBar: React.FC = () => { {themes .filter((t) => t.name !== theme.name) .map((t) => ( - theme.toggleTheme!(t.name)}>{t.name} + theme.toggleTheme!(t.name)}> + {t.name} + ))} diff --git a/src/frontend/pages/HomePage/upcoming-deadlines/upcoming-deadlines.test.tsx b/src/frontend/pages/HomePage/upcoming-deadlines/upcoming-deadlines.test.tsx index 0c419596..6443698c 100644 --- a/src/frontend/pages/HomePage/upcoming-deadlines/upcoming-deadlines.test.tsx +++ b/src/frontend/pages/HomePage/upcoming-deadlines/upcoming-deadlines.test.tsx @@ -5,7 +5,7 @@ import { UseQueryResult } from 'react-query'; import { WorkPackage } from 'utils'; -import { useAllWorkPackagesUpcomingDeadlines } from '../../../../services/work-packages.hooks'; +import { useAllWorkPackages } from '../../../../services/work-packages.hooks'; import { datePipe, fullNamePipe } from '../../../../shared/pipes'; import { mockUseQueryResult } from '../../../../test-support/test-data/test-utils.stub'; import { exampleAllWorkPackages } from '../../../../test-support/test-data/work-packages.stub'; @@ -14,9 +14,7 @@ import UpcomingDeadlines from './upcoming-deadlines'; jest.mock('../../../../services/work-packages.hooks'); -const mockedUseAllWPs = useAllWorkPackagesUpcomingDeadlines as jest.Mock< - UseQueryResult ->; +const mockedUseAllWPs = useAllWorkPackages as jest.Mock>; const mockHook = (isLoading: boolean, isError: boolean, data?: WorkPackage[], error?: Error) => { mockedUseAllWPs.mockReturnValue( @@ -40,7 +38,7 @@ describe('upcoming deadlines component', () => { it('renders headers', () => { mockHook(false, false, []); renderComponent(); - expect(screen.getByText('Upcoming Deadlines')).toBeInTheDocument(); + expect(screen.getByText('Upcoming Deadlines (0)')).toBeInTheDocument(); }); it('renders loading indicator', () => { @@ -79,9 +77,11 @@ describe('upcoming deadlines component', () => { expect(screen.getByText('No upcoming deadlines')).toBeInTheDocument(); }); - it('renders work package count', () => { + it('renders time period selector', () => { mockHook(false, false, exampleAllWorkPackages); renderComponent(); - expect(screen.getByText('3 Work Packages')).toBeInTheDocument(); + expect(screen.getByText('Next')).toBeInTheDocument(); + expect(screen.getByText('14')).toBeInTheDocument(); + expect(screen.getByText('Days')).toBeInTheDocument(); }); }); diff --git a/src/frontend/pages/HomePage/upcoming-deadlines/upcoming-deadlines.tsx b/src/frontend/pages/HomePage/upcoming-deadlines/upcoming-deadlines.tsx index 8fce4058..3c663123 100644 --- a/src/frontend/pages/HomePage/upcoming-deadlines/upcoming-deadlines.tsx +++ b/src/frontend/pages/HomePage/upcoming-deadlines/upcoming-deadlines.tsx @@ -3,10 +3,12 @@ * See the LICENSE file in the repository root folder for details. */ -import { Card, Container, Row } from 'react-bootstrap'; +import { useState } from 'react'; +import { Card, Container, Form, InputGroup, Row } from 'react-bootstrap'; import { Link } from 'react-router-dom'; +import { WbsElementStatus } from 'utils'; import { useTheme } from '../../../../services/theme.hooks'; -import { useAllWorkPackagesUpcomingDeadlines } from '../../../../services/work-packages.hooks'; +import { useAllWorkPackages } from '../../../../services/work-packages.hooks'; import { datePipe, wbsPipe, fullNamePipe, percentPipe } from '../../../../shared/pipes'; import { routes } from '../../../../shared/routes'; import LoadingIndicator from '../../../components/loading-indicator/loading-indicator'; @@ -15,8 +17,9 @@ import ErrorPage from '../../ErrorPage/error-page'; import styles from './upcoming-deadlines.module.css'; const UpcomingDeadlines: React.FC = () => { + const [daysUntilDeadline, setDaysUntilDeadline] = useState('14'); const theme = useTheme(); - const workPackages = useAllWorkPackagesUpcomingDeadlines(); + const workPackages = useAllWorkPackages({ status: WbsElementStatus.Active, daysUntilDeadline }); if (workPackages.isError) { return ; @@ -28,6 +31,7 @@ const UpcomingDeadlines: React.FC = () => { ? 'No upcoming deadlines' : workPackages.data?.map((wp) => ( { return ( : <>{workPackages.data?.length} Work Packages} + title={`Upcoming Deadlines (${workPackages.data?.length})`} + headerRight={ + + + Next + + setDaysUntilDeadline(e.target.value)} + > + {['1', '2', '5', '7', '14', '21', '30'].map((days) => ( + + ))} + + + Days + + + } body={ {workPackages.isLoading ? : fullDisplay} } diff --git a/src/frontend/pages/HomePage/work-packages-by-timeline-status/work-packages-by-timeline-status.test.tsx b/src/frontend/pages/HomePage/work-packages-by-timeline-status/work-packages-by-timeline-status.test.tsx index 03566a4a..5bfaa87b 100644 --- a/src/frontend/pages/HomePage/work-packages-by-timeline-status/work-packages-by-timeline-status.test.tsx +++ b/src/frontend/pages/HomePage/work-packages-by-timeline-status/work-packages-by-timeline-status.test.tsx @@ -38,7 +38,7 @@ describe('upcoming deadlines component', () => { it('renders headers', () => { mockHook(false, false, []); renderComponent(); - expect(screen.getByText('Work Packages By Timeline Status')).toBeInTheDocument(); + expect(screen.getByText('Work Packages By Timeline Status (0)')).toBeInTheDocument(); }); it('renders loading indicator', () => { @@ -80,7 +80,7 @@ describe('upcoming deadlines component', () => { it('renders timeline status selector', () => { mockHook(false, false, exampleAllWorkPackages); renderComponent(); - expect(screen.getByText('Status:')).toBeInTheDocument(); + expect(screen.getByText('Timeline Status')).toBeInTheDocument(); expect(screen.getByText('VERY_BEHIND')).toBeInTheDocument(); }); }); diff --git a/src/frontend/pages/HomePage/work-packages-by-timeline-status/work-packages-by-timeline-status.tsx b/src/frontend/pages/HomePage/work-packages-by-timeline-status/work-packages-by-timeline-status.tsx index 75190028..e055092c 100644 --- a/src/frontend/pages/HomePage/work-packages-by-timeline-status/work-packages-by-timeline-status.tsx +++ b/src/frontend/pages/HomePage/work-packages-by-timeline-status/work-packages-by-timeline-status.tsx @@ -4,7 +4,7 @@ */ import { useState, useEffect } from 'react'; -import { Card, Container, Form, Row } from 'react-bootstrap'; +import { Card, Container, Form, InputGroup, Row } from 'react-bootstrap'; import { Link } from 'react-router-dom'; import { TimelineStatus, WbsElementStatus } from 'utils'; import { useTheme } from '../../../../services/theme.hooks'; @@ -19,7 +19,7 @@ import styles from './work-packages-by-timeline-status.module.css'; const WorkPackagesByTimelineStatus: React.FC = () => { const [timelineStatus, setTimelineStatus] = useState(TimelineStatus.VeryBehind); const theme = useTheme(); - const workPackages = useAllWorkPackages(WbsElementStatus.Active, timelineStatus); + const workPackages = useAllWorkPackages({ status: WbsElementStatus.Active, timelineStatus }); useEffect(() => { workPackages.refetch(); @@ -35,7 +35,12 @@ const WorkPackagesByTimelineStatus: React.FC = () => { {workPackages.data?.length === 0 ? `No ${timelineStatus} work packages` : workPackages.data?.map((wp) => ( - + @@ -64,27 +69,26 @@ const WorkPackagesByTimelineStatus: React.FC = () => { return ( -
Status:
+ + + Timeline Status + setTimelineStatus(e.target.value as TimelineStatus)} custom > - - {Object.values(TimelineStatus) - .filter((status) => status !== timelineStatus) - .map((status) => ( - - ))} + {Object.values(TimelineStatus).map((status) => ( + + ))} - + } body={ {workPackages.isLoading ? : fullDisplay} diff --git a/src/frontend/pages/LoginPage/login.tsx b/src/frontend/pages/LoginPage/login.tsx index 3b3ff811..55bcab74 100644 --- a/src/frontend/pages/LoginPage/login.tsx +++ b/src/frontend/pages/LoginPage/login.tsx @@ -28,7 +28,6 @@ const Login: React.FC = ({ postLoginRedirect }) => { if (auth.isLoading) return ; const redirectAfterLogin = () => { - console.log(postLoginRedirect); if (postLoginRedirect.url === routes.LOGIN) { history.push(routes.HOME); } else { diff --git a/src/services/work-packages.api.ts b/src/services/work-packages.api.ts index 836596f6..1341f193 100644 --- a/src/services/work-packages.api.ts +++ b/src/services/work-packages.api.ts @@ -4,14 +4,7 @@ */ import axios from 'axios'; -import { - CreateWorkPackagePayload, - EditWorkPackagePayload, - TimelineStatus, - WbsElementStatus, - WbsNumber, - WorkPackage -} from 'utils'; +import { CreateWorkPackagePayload, EditWorkPackagePayload, WbsNumber, WorkPackage } from 'utils'; import { wbsPipe } from '../shared/pipes'; import { apiUrls } from '../shared/urls'; import { workPackageTransformer } from './transformers/work-packages.transformers'; @@ -19,8 +12,8 @@ import { workPackageTransformer } from './transformers/work-packages.transformer /** * Fetch all work packages. */ -export const getAllWorkPackages = (status?: WbsElementStatus, timelineStatus?: TimelineStatus) => { - return axios.get(apiUrls.workPackages(status, timelineStatus), { +export const getAllWorkPackages = (queryParams?: { [field: string]: string }) => { + return axios.get(apiUrls.workPackages(queryParams), { transformResponse: (data) => JSON.parse(data).map(workPackageTransformer) }); }; @@ -58,12 +51,3 @@ export const editWorkPackage = (payload: EditWorkPackagePayload) => { ...payload }); }; - -/** - * Fetch all work packages with upcoming deadlines. - */ -export const getAllWorkPackagesUpcomingDeadlines = () => { - return axios.get(apiUrls.workPackagesUpcomingDeadlines(), { - transformResponse: (data) => JSON.parse(data).map(workPackageTransformer) - }); -}; diff --git a/src/services/work-packages.hooks.ts b/src/services/work-packages.hooks.ts index b2228280..e65170c5 100644 --- a/src/services/work-packages.hooks.ts +++ b/src/services/work-packages.hooks.ts @@ -4,28 +4,20 @@ */ import { useMutation, useQuery } from 'react-query'; -import { - WorkPackage, - WbsNumber, - CreateWorkPackagePayload, - EditWorkPackagePayload, - WbsElementStatus, - TimelineStatus -} from 'utils'; +import { WorkPackage, WbsNumber, CreateWorkPackagePayload, EditWorkPackagePayload } from 'utils'; import { createSingleWorkPackage, editWorkPackage, getAllWorkPackages, - getAllWorkPackagesUpcomingDeadlines, getSingleWorkPackage } from './work-packages.api'; /** * Custom React Hook to supply all work packages. */ -export const useAllWorkPackages = (status?: WbsElementStatus, timelineStatus?: TimelineStatus) => { - return useQuery(['work packages'], async () => { - const { data } = await getAllWorkPackages(status, timelineStatus); +export const useAllWorkPackages = (queryParams?: { [field: string]: string }) => { + return useQuery(['work packages', queryParams], async () => { + const { data } = await getAllWorkPackages(queryParams); return data; }); }; @@ -76,13 +68,3 @@ export const useEditWorkPackage = () => { } ); }; - -/** - * Custom React Hook to supply all work packages with an upcoming deadline. - */ -export const useAllWorkPackagesUpcomingDeadlines = () => { - return useQuery(['work packages', 'upcoming deadlines'], async () => { - const { data } = await getAllWorkPackagesUpcomingDeadlines(); - return data; - }); -}; diff --git a/src/shared/pipes.tsx b/src/shared/pipes.tsx index 197486ba..0c678d30 100644 --- a/src/shared/pipes.tsx +++ b/src/shared/pipes.tsx @@ -27,7 +27,7 @@ export const linkPipe = (description: string, link: string): ReactElement => { export const iconLinkPipe = (icon: IconProp, description: string, link: string) => { return ( -
+
{linkPipe(description, link)}
diff --git a/src/shared/tests/urls.test.tsx b/src/shared/tests/urls.test.tsx new file mode 100644 index 00000000..6422aa78 --- /dev/null +++ b/src/shared/tests/urls.test.tsx @@ -0,0 +1,64 @@ +/* + * This file is part of NER's PM Dashboard and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import { apiUrls } from '../urls'; + +describe('API URLs Tests', () => { + describe('all work packages urls', () => { + it('should return the correct url for all work packages base', () => { + expect(apiUrls.workPackages()).toEqual('/.netlify/functions/work-packages'); + }); + + it('should return the correct url for all work packages filtered by status', () => { + expect(apiUrls.workPackages({ status: 'ACTIVE' })).toEqual( + '/.netlify/functions/work-packages?status=ACTIVE' + ); + }); + + it('should return the correct url for all work packages filtered by timelineStatus', () => { + expect(apiUrls.workPackages({ timelineStatus: 'BEHIND' })).toEqual( + '/.netlify/functions/work-packages?timelineStatus=BEHIND' + ); + }); + + it('should return the correct url for all work packages filtered by daysUntilDeadline', () => { + expect(apiUrls.workPackages({ daysUntilDeadline: '14' })).toEqual( + '/.netlify/functions/work-packages?daysUntilDeadline=14' + ); + }); + + it('should return the correct url for all work packages filtered by status and timelineStatus', () => { + expect(apiUrls.workPackages({ status: 'ACTIVE', timelineStatus: 'AHEAD' })).toEqual( + '/.netlify/functions/work-packages?status=ACTIVE&timelineStatus=AHEAD' + ); + }); + + it('should return the correct url for all work packages filtered by status and daysUntilDeadline', () => { + expect(apiUrls.workPackages({ status: 'COMPLETE', daysUntilDeadline: '12' })).toEqual( + '/.netlify/functions/work-packages?status=COMPLETE&daysUntilDeadline=12' + ); + }); + + it('should return the correct url for all work packages filtered by timelineStatus and daysUntilDeadline', () => { + expect( + apiUrls.workPackages({ timelineStatus: 'VERY_BEHIND', daysUntilDeadline: '10' }) + ).toEqual( + '/.netlify/functions/work-packages?timelineStatus=VERY_BEHIND&daysUntilDeadline=10' + ); + }); + + it('should return the correct url for all work packages filtered by status, timelineStatus, and daysUntilDeadline', () => { + expect( + apiUrls.workPackages({ + status: 'INACTIVE', + timelineStatus: 'ON_TRACK', + daysUntilDeadline: '7' + }) + ).toEqual( + '/.netlify/functions/work-packages?status=INACTIVE&timelineStatus=ON_TRACK&daysUntilDeadline=7' + ); + }); + }); +}); diff --git a/src/shared/urls.ts b/src/shared/urls.ts index baea0c96..d0403aa7 100644 --- a/src/shared/urls.ts +++ b/src/shared/urls.ts @@ -21,13 +21,14 @@ const projectsCreate = () => `${projects()}-new`; const projectsEdit = () => `${projects()}-edit`; /**************** Work Packages Endpoint ****************/ -const workPackages = (status?: string, timelineStatus?: string) => { - const base = `${API_URL}/work-packages`; - if (status && timelineStatus) return `${base}?status=${status}&timelineStatus=${timelineStatus}`; - return base; +const workPackages = (queryParams?: { [field: string]: string }) => { + const url = `${API_URL}/work-packages`; + if (!queryParams) return url; + return `${url}?${Object.keys(queryParams) + .map((param) => `${param}=${queryParams[param]}`) + .join('&')}`; }; const workPackagesByWbsNum = (wbsNum: string) => `${workPackages()}/${wbsNum}`; -const workPackagesUpcomingDeadlines = () => `${workPackages()}/upcoming-deadlines`; const workPackagesCreate = () => `${workPackages()}-create`; const workPackagesEdit = () => `${workPackages()}-edit`; @@ -52,7 +53,6 @@ export const apiUrls = { workPackages, workPackagesByWbsNum, - workPackagesUpcomingDeadlines, workPackagesCreate, workPackagesEdit, diff --git a/src/utils/src/api-routes.ts b/src/utils/src/api-routes.ts index 57a16a55..9735966f 100644 --- a/src/utils/src/api-routes.ts +++ b/src/utils/src/api-routes.ts @@ -18,7 +18,6 @@ const PROJECTS_EDIT: string = `${PROJECTS}-edit`; /**************** Work Packages Endpoint ****************/ const WORK_PACKAGES: string = `/work-packages`; const WORK_PACKAGES_BY_WBS: string = `${WORK_PACKAGES}/:wbsNum`; -const WORK_PACKAGES_UPCOMING_DEADLINES: string = `${WORK_PACKAGES}/upcoming-deadlines`; const WORK_PACKAGES_CREATE: string = `${WORK_PACKAGES}-create`; const WORK_PACKAGES_EDIT: string = `${WORK_PACKAGES}-edit`; @@ -39,7 +38,6 @@ export const apiRoutes = { WORK_PACKAGES, WORK_PACKAGES_BY_WBS, - WORK_PACKAGES_UPCOMING_DEADLINES, WORK_PACKAGES_CREATE, WORK_PACKAGES_EDIT,