diff --git a/packages/api/package.json b/packages/api/package.json index d78c283bd..458a2c20f 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -104,7 +104,7 @@ "rxjs": "^7", "sharp": "^0.32.6", "ts-exif-parser": "^0.2.1", - "typeorm": "^0.3.18", + "typeorm": "^0.3.19", "typeorm-naming-strategies": "^1.1.0" }, "devDependencies": { diff --git a/packages/api/src/sites/sites.service.ts b/packages/api/src/sites/sites.service.ts index 4332db4ef..96025653c 100644 --- a/packages/api/src/sites/sites.service.ts +++ b/packages/api/src/sites/sites.service.ts @@ -188,6 +188,7 @@ export class SitesService { .leftJoinAndSelect('site.region', 'region') .leftJoinAndSelect('site.sketchFab', 'sketchFab') .leftJoinAndSelect('site.admins', 'admins') + .leftJoinAndSelect('site.reefCheckSite', 'reefCheckSite') .andWhere('display = true') .getMany(); diff --git a/packages/api/src/time-series/time-series.spec.ts b/packages/api/src/time-series/time-series.spec.ts index d138a444b..b6603a712 100644 --- a/packages/api/src/time-series/time-series.spec.ts +++ b/packages/api/src/time-series/time-series.spec.ts @@ -43,6 +43,8 @@ export const timeSeriesTests = () => { ]; beforeAll(async () => { + // Define missing global function in test environment + global.structuredClone = (val) => JSON.parse(JSON.stringify(val)); app = await testService.getApp(); dataSource = await testService.getDataSource(); }); diff --git a/packages/api/test/jest.json b/packages/api/test/jest.json index e31d66d6d..eb910fc98 100644 --- a/packages/api/test/jest.json +++ b/packages/api/test/jest.json @@ -29,6 +29,8 @@ "globalSetup": "./test/global-setup.js", "moduleNameMapper": { "^csv-stringify/sync": - "/../../node_modules/csv-stringify/dist/cjs/sync.cjs" + "/../../node_modules/csv-stringify/dist/cjs/sync.cjs", + "^typeorm$": "/../../node_modules/typeorm", + "^uuid$": "uuid" } } diff --git a/packages/website/package.json b/packages/website/package.json index 4ca676901..b1df7f224 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -60,6 +60,7 @@ "react-material-ui-carousel": "3.4.2", "react-redux": "^7.2.0", "react-router-dom": "^6.0.0", + "react-router-hash-link": "^2.4.3", "react-slick": "^0.30.3", "react-swipeable-bottom-sheet": "^1.1.2", "react-swipeable-views": "^0.14.0", @@ -76,7 +77,8 @@ "axios": "axios/dist/node/axios.cjs", "^csv-stringify/browser/esm/sync": "/../../node_modules/csv-stringify/dist/cjs/sync.cjs" }, - "globalSetup": "./src/global-setup.js" + "globalSetup": "./src/global-setup.js", + "resetMocks": false }, "browserslist": { "production": [ @@ -110,6 +112,7 @@ "@types/react-leaflet": "^2.5.1", "@types/react-redux": "^7.1.7", "@types/react-router-dom": "^5.1.3", + "@types/react-router-hash-link": "^2.4.9", "@types/react-slick": "^0.23.4", "@types/react-swipeable-views": "^0.13.0", "@types/redux-mock-store": "^1.0.2", diff --git a/packages/website/src/assets/img/reef-check-logo.png b/packages/website/src/assets/img/reef-check-logo.png new file mode 100644 index 000000000..65fb1c94c Binary files /dev/null and b/packages/website/src/assets/img/reef-check-logo.png differ diff --git a/packages/website/src/assets/img/reef-check.png b/packages/website/src/assets/img/reef-check.png new file mode 100644 index 000000000..1912553e3 Binary files /dev/null and b/packages/website/src/assets/img/reef-check.png differ diff --git a/packages/website/src/common/SiteDetails/FeaturedMedia/__snapshots__/index.test.tsx.snap b/packages/website/src/common/SiteDetails/FeaturedMedia/__snapshots__/index.test.tsx.snap index f095a1e9f..969882ee5 100644 --- a/packages/website/src/common/SiteDetails/FeaturedMedia/__snapshots__/index.test.tsx.snap +++ b/packages/website/src/common/SiteDetails/FeaturedMedia/__snapshots__/index.test.tsx.snap @@ -11,16 +11,26 @@ exports[`Featured Media Card should render with given state from Redux store 1`]
-
+ Reef Check - SURVEY TO BE UPLOADED + REEF CHECK DATA AVAILABLE -
+ +
{ let element: HTMLElement; beforeEach(() => { const store = mockStore({ + reefCheckSurveyList: { + list: [], + }, user: { userInfo: mockUser, }, diff --git a/packages/website/src/common/SiteDetails/FeaturedMedia/index.tsx b/packages/website/src/common/SiteDetails/FeaturedMedia/index.tsx index 1a501cdbe..a6925072f 100644 --- a/packages/website/src/common/SiteDetails/FeaturedMedia/index.tsx +++ b/packages/website/src/common/SiteDetails/FeaturedMedia/index.tsx @@ -1,3 +1,4 @@ +/* eslint-disable no-nested-ternary */ import React from 'react'; import { Card, @@ -7,18 +8,23 @@ import { Typography, IconButton, Theme, + Box, } from '@mui/material'; import { WithStyles } from '@mui/styles'; import withStyles from '@mui/styles/withStyles'; import createStyles from '@mui/styles/createStyles'; +import { KeyboardDoubleArrowDown } from '@mui/icons-material'; import { Link } from 'react-router-dom'; +import { HashLink } from 'react-router-hash-link'; import { useSelector } from 'react-redux'; import { userInfoSelector } from 'store/User/userSlice'; import { isAdmin } from 'helpers/user'; import { convertOptionsToQueryParams } from 'helpers/video'; +import { reefCheckSurveyListSelector } from 'store/ReefCheckSurveys'; import reefImage from '../../../assets/reef-image.jpg'; import uploadIcon from '../../../assets/icon_upload.svg'; +import reefCheckLogo from '../../../assets/img/reef-check-logo.png'; const playerOptions = { autoplay: 1, @@ -35,6 +41,10 @@ const FeaturedMedia = ({ classes, }: FeaturedMediaProps) => { const user = useSelector(userInfoSelector); + const { list: reefCheckSurveyList } = useSelector( + reefCheckSurveyListSelector, + ); + const hasReefCheckSurveys = true || reefCheckSurveyList.length > 0; const isSiteAdmin = isAdmin(user, siteId); if (url) { @@ -71,21 +81,43 @@ const FeaturedMedia = ({
- + {isSiteAdmin ? ( + <> + + + ADD YOUR FIRST SURVEY + + + + + upload + + + + ) : hasReefCheckSurveys ? ( + + Reef Check + REEF CHECK DATA AVAILABLE + + + ) : ( - {isSiteAdmin ? 'ADD YOUR FIRST SURVEY' : 'SURVEY TO BE UPLOADED'} + SURVEY TO BE UPLOADED - - {isSiteAdmin && ( - - - upload - - )}
@@ -118,7 +150,7 @@ const styles = (theme: Theme) => { zIndex: 1, }, noVideoCardHeaderText: { - opacity: 0.5, + color: 'white', [theme.breakpoints.between('md', 1350)]: { fontSize: 15, }, diff --git a/packages/website/src/common/SiteDetails/Surveys/ReefCheckSurveyCard/index.test.tsx b/packages/website/src/common/SiteDetails/Surveys/ReefCheckSurveyCard/index.test.tsx new file mode 100644 index 000000000..3fa1dc868 --- /dev/null +++ b/packages/website/src/common/SiteDetails/Surveys/ReefCheckSurveyCard/index.test.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { mockReefCheckSurvey } from 'mocks/mockReefCheckSurvey'; +import { ReefCheckSurvey } from 'store/ReefCheckSurveys'; +import { BrowserRouter } from 'react-router-dom'; +import { ThemeProvider } from '@mui/material'; +import theme from 'layout/App/theme'; +import { ReefCheckSurveyCard } from '.'; + +describe('ReefCheckSurveyCard', () => { + function renderReefCheckSurveyCard(overrides: Partial = {}) { + return render( + + + + + , + ); + } + + it('should render date', () => { + const { getByText } = renderReefCheckSurveyCard(); + + expect( + getByText(`Date: ${new Date(mockReefCheckSurvey.date).toLocaleString()}`), + ).toBeInTheDocument(); + }); + + it('should render user if submittedBy is present', () => { + const { getByText } = renderReefCheckSurveyCard({ + submittedBy: 'Test User', + }); + expect(getByText('User: Test User')).toBeInTheDocument(); + }); + + it('should render table with correct number of rows', () => { + const { container } = renderReefCheckSurveyCard(); + + expect(container.querySelectorAll('mock-tablerow').length).toBe(3); + }); + + it('should show correct counts in headers', () => { + const { container } = renderReefCheckSurveyCard(); + const headers = [ + ...container.querySelectorAll('mock-tablehead mock-tablecell').values(), + ].map((el) => el.textContent); + expect(headers).toEqual( + expect.arrayContaining([ + 'FISH (2)', + 'Count', + 'INVERTEBRATES (2)', + 'Count', + 'BLEACHING AND CORAL DIDEASES', + 'YES/NO', + 'IMPACT', + 'YES/NO', + ]), + ); + }); + + it('should display link to survey details', () => { + const { getByRole } = renderReefCheckSurveyCard(); + + expect(getByRole('link', { name: 'VIEW DETAILS' })).toHaveAttribute( + 'href', + `/reef_check_survey/${mockReefCheckSurvey.id}`, + ); + }); +}); diff --git a/packages/website/src/common/SiteDetails/Surveys/ReefCheckSurveyCard/index.tsx b/packages/website/src/common/SiteDetails/Surveys/ReefCheckSurveyCard/index.tsx new file mode 100644 index 000000000..3efcbe769 --- /dev/null +++ b/packages/website/src/common/SiteDetails/Surveys/ReefCheckSurveyCard/index.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { + Box, + Button, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Theme, + Typography, +} from '@mui/material'; +import { createStyles, WithStyles } from '@mui/styles'; +import withStyles from '@mui/styles/withStyles'; +import { groupBy, times } from 'lodash'; +import { Link } from 'react-router-dom'; +import cls from 'classnames'; +import { reefCheckImpactRows, ReefCheckSurvey } from 'store/ReefCheckSurveys'; + +type ReefCheckSurveyCardIncomingProps = { + survey: ReefCheckSurvey; +}; + +const ReefCheckSurveyCardComponent = ({ + survey, + classes, +}: ReefCheckSurveyCardProps) => { + const stats = groupBy( + // eslint-disable-next-line fp/no-mutating-methods + survey.organisms + .map((organism) => ({ + ...organism, + count: organism.s1 + organism.s2 + organism.s3 + organism.s4, + })) + .filter( + ({ count, type }) => + // Filter out fish and invertebrates with no count + count > 0 || type === 'Impact' || type === 'Bleaching', + ) + .sort((a, b) => b.count - a.count), + ({ type, organism }) => { + if (type === 'Impact') { + return reefCheckImpactRows.includes(organism) ? 'Impact' : 'Bleaching'; + } + return type; + }, + ); + + const rowCount = Math.max( + stats.Fish?.length ?? 0, + stats.Invertebrate?.length ?? 0, + stats.Bleaching?.length ?? 0, + stats.Impact?.length ?? 0, + ); + + return ( + + + Date: {new Date(survey.date).toLocaleString()} + {survey.submittedBy && ( + User: {survey.submittedBy} + )} + + + + + + + FISH ({stats.Fish?.length ?? 0}) + + Count + + INVERTEBRATES ({stats.Invertebrate?.length ?? 0}) + + Count + + BLEACHING AND CORAL DIDEASES + + YES/NO + IMPACT + YES/NO + + + + {times(rowCount).map((i) => ( + + + {stats.Fish?.[i]?.organism} + + {stats.Fish?.[i]?.count} + + {stats.Invertebrate?.[i]?.organism} + + {stats.Invertebrate?.[i]?.count} + + {stats.Bleaching?.[i]?.organism} + + + {formatImpactCount(stats.Bleaching?.[i]?.count)} + + + {stats.Impact?.[i]?.organism} + + + {formatImpactCount(stats.Impact?.[i]?.count)} + + + ))} + +
+
+ + + + + + +
+ ); +}; + +const formatImpactCount = (count?: number) => { + if (count === undefined) { + return ''; + } + return count > 0 ? 'YES' : 'NO'; +}; + +const styles = (theme: Theme) => + createStyles({ + paper: { + padding: 16, + color: theme.palette.text.secondary, + maxWidth: '100%', + }, + label: { + backgroundColor: '#FAFAFA', + }, + tableRoot: { + maxHeight: 200, + }, + header: { + '& th': { + borderBottom: '1px solid black', + }, + }, + noWrap: { + whiteSpace: 'nowrap', + }, + }); + +type ReefCheckSurveyCardProps = ReefCheckSurveyCardIncomingProps & + WithStyles; + +export const ReefCheckSurveyCard = withStyles(styles)( + ReefCheckSurveyCardComponent, +); diff --git a/packages/website/src/common/SiteDetails/Surveys/Timeline/Desktop.tsx b/packages/website/src/common/SiteDetails/Surveys/Timeline/Desktop.tsx index 52b929dd7..7f8aa17c8 100644 --- a/packages/website/src/common/SiteDetails/Surveys/Timeline/Desktop.tsx +++ b/packages/website/src/common/SiteDetails/Surveys/Timeline/Desktop.tsx @@ -11,6 +11,8 @@ import { Theme, Typography } from '@mui/material'; import makeStyles from '@mui/styles/makeStyles'; import classNames from 'classnames'; import { displayTimeInLocalTimezone } from 'helpers/dates'; +import { times } from 'lodash'; +import { SurveyListItem } from 'store/Survey/types'; import { grey } from '@mui/material/colors'; import AddButton from '../AddButton'; @@ -18,6 +20,7 @@ import SurveyCard from '../SurveyCard'; import LoadingSkeleton from '../../../LoadingSkeleton'; import incomingStyles from '../styles'; import { TimelineProps } from './types'; +import { ReefCheckSurveyCard } from '../ReefCheckSurveyCard'; const CONNECTOR_COLOR = grey[500]; @@ -49,7 +52,7 @@ const TimelineDesktop = ({ )} - {surveys.map((survey, index) => ( + {(loading ? times(2, () => null) : surveys).map((survey, index) => ( - {survey?.diveDate && ( + {survey?.date && ( {displayTimeInLocalTimezone({ - isoDate: survey.diveDate, + isoDate: survey.date, format: 'LL/dd/yyyy', displayTimezone: false, timeZone, @@ -80,14 +83,19 @@ const TimelineDesktop = ({
- + {(survey?.type === 'survey' || loading) && ( + + )} + {survey?.type === 'reefCheckSurvey' && ( + + )}
))} @@ -101,7 +109,7 @@ const useStyles = makeStyles((theme: Theme) => ({ alignItems: 'center', }, timelineOppositeContent: { - flex: 0.5, + flex: '0 0 130px', }, addNewButtonOpposite: { padding: theme.spacing(0, 1.25), diff --git a/packages/website/src/common/SiteDetails/Surveys/Timeline/Tablet.tsx b/packages/website/src/common/SiteDetails/Surveys/Timeline/Tablet.tsx index d675fea32..878d33359 100644 --- a/packages/website/src/common/SiteDetails/Surveys/Timeline/Tablet.tsx +++ b/packages/website/src/common/SiteDetails/Surveys/Timeline/Tablet.tsx @@ -4,11 +4,13 @@ import { Grid, Typography } from '@mui/material'; import makeStyles from '@mui/styles/makeStyles'; import { displayTimeInLocalTimezone } from 'helpers/dates'; +import { times } from 'lodash'; import AddButton from '../AddButton'; import SurveyCard from '../SurveyCard'; import incomingStyles from '../styles'; import { TimelineProps } from './types'; import LoadingSkeleton from '../../../LoadingSkeleton'; +import { ReefCheckSurveyCard } from '../ReefCheckSurveyCard'; const TimelineTablet = ({ siteId, @@ -36,7 +38,7 @@ const TimelineTablet = ({ {isSiteIdValid && } )} - {surveys.map((survey, index) => ( + {(loading ? times(2, () => null) : surveys).map((survey, index) => ( - {survey?.diveDate && ( + {survey?.date && ( {displayTimeInLocalTimezone({ - isoDate: survey.diveDate, + isoDate: survey.date, format: 'LL/dd/yyyy', displayTimezone: false, timeZone, @@ -65,14 +67,19 @@ const TimelineTablet = ({ - + {survey?.type === 'survey' && ( + + )} + {survey?.type === 'reefCheckSurvey' && ( + + )} ))} diff --git a/packages/website/src/common/SiteDetails/Surveys/Timeline/index.tsx b/packages/website/src/common/SiteDetails/Surveys/Timeline/index.tsx index c15870a81..424146e9d 100644 --- a/packages/website/src/common/SiteDetails/Surveys/Timeline/index.tsx +++ b/packages/website/src/common/SiteDetails/Surveys/Timeline/index.tsx @@ -9,6 +9,8 @@ import createStyles from '@mui/styles/createStyles'; import { surveyListSelector } from 'store/Survey/surveyListSlice'; import { SurveyMedia } from 'store/Survey/types'; import { filterSurveys } from 'helpers/surveys'; +import { sortByDate } from 'helpers/dates'; +import { reefCheckSurveyListSelector } from 'store/ReefCheckSurveys'; import TimelineDesktop from './Desktop'; import TimelineTablet from './Tablet'; import { TimelineProps } from './types'; @@ -25,22 +27,38 @@ const SurveyTimeline = ({ classes, }: SurveyTimelineProps) => { const surveyList = useSelector(surveyListSelector); + const { list: reefCheckSurveyList = [] } = + useSelector(reefCheckSurveyListSelector) ?? {}; + const displayAddButton = isAdmin && addNewButton && !(window && window.location.pathname.includes('new_survey')); - // If the site is loading, then display two survey card skeletons, - // else display the actual survey cards. - const filteredSurveys = loading - ? [null, null] - : filterSurveys(surveyList, observation, pointId); + + // Combine surveys and reef check surveys into a single list + const mergedSurveys: TimelineProps['surveys'] = sortByDate( + [ + ...filterSurveys(surveyList, observation, pointId).map((s) => ({ + ...s, + date: s.diveDate ?? '', + type: 'survey' as const, + })), + ...reefCheckSurveyList?.map((s) => ({ + ...s, + date: s.date ?? '', + type: 'reefCheckSurvey' as const, + })), + ], + 'date', + 'desc', + ); const timelineProps: TimelineProps = { siteId, loading, isAdmin, pointId, pointName, - surveys: filteredSurveys, + surveys: mergedSurveys, timeZone, displayAddButton, }; diff --git a/packages/website/src/common/SiteDetails/Surveys/Timeline/types.ts b/packages/website/src/common/SiteDetails/Surveys/Timeline/types.ts index 69ce822bc..212c05627 100644 --- a/packages/website/src/common/SiteDetails/Surveys/Timeline/types.ts +++ b/packages/website/src/common/SiteDetails/Surveys/Timeline/types.ts @@ -1,12 +1,18 @@ +import { ReefCheckSurvey } from 'store/ReefCheckSurveys'; import { SurveyListItem } from 'store/Survey/types'; export interface TimelineProps { siteId?: number; loading?: boolean; displayAddButton: boolean; - surveys: (SurveyListItem | null)[]; + surveys: TimelineSurvey[]; pointId: number; pointName: string | null; isAdmin: boolean; timeZone?: string | null; } + +type TimelineSurvey = { date: string } & ( + | (SurveyListItem & { type: 'survey' }) + | (ReefCheckSurvey & { type: 'reefCheckSurvey' }) +); diff --git a/packages/website/src/common/SiteDetails/Surveys/__snapshots__/index.test.tsx.snap b/packages/website/src/common/SiteDetails/Surveys/__snapshots__/index.test.tsx.snap index 6de80be26..98c0ceaa5 100644 --- a/packages/website/src/common/SiteDetails/Surveys/__snapshots__/index.test.tsx.snap +++ b/packages/website/src/common/SiteDetails/Surveys/__snapshots__/index.test.tsx.snap @@ -47,6 +47,7 @@ exports[`Surveys should render with given state from Redux store 1`] = ` diff --git a/packages/website/src/common/SiteDetails/Surveys/index.tsx b/packages/website/src/common/SiteDetails/Surveys/index.tsx index 1bde86485..2bd3caadd 100644 --- a/packages/website/src/common/SiteDetails/Surveys/index.tsx +++ b/packages/website/src/common/SiteDetails/Surveys/index.tsx @@ -210,6 +210,7 @@ const Surveys = ({ site }: SurveysProps) => { spacing={2} > + + + Reef Check + +
diff --git a/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/ReefCheckSurveyDetails.tsx b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/ReefCheckSurveyDetails.tsx new file mode 100644 index 000000000..ba775dc0e --- /dev/null +++ b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/ReefCheckSurveyDetails.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { + Box, + Grid, + Paper, + Theme, + Typography, + Skeleton +} from '@mui/material'; +import withStyles from '@mui/styles/withStyles'; +import { createStyles, WithStyles } from '@mui/styles'; +import { useSelector } from 'react-redux'; +import { reefCheckSurveySelector } from 'store/ReefCheckSurveys/reefCheckSurveySlice'; +import { ReefCheckSurvey } from 'store/ReefCheckSurveys/types'; + +type SurveyField = { + field: T; + label: string; + formatter?: (v: ReefCheckSurvey[T]) => string; +}; +type SurveyFields = { [K in keyof ReefCheckSurvey]: SurveyField }; + +const surveyFields = [ + { field: 'depth', label: 'Survey Depth', formatter: (v) => `${v}m` }, + { field: 'timeOfDayWorkBegan', label: 'Time of Day Work Began' }, + { field: 'weather', label: 'Weather' }, + { field: 'airTemp', label: 'Air Temperature', formatter: (v) => `${v}°C` }, + { + field: 'waterTempAtSurface', + label: 'Water Temperature at Surface', + formatter: (v) => `${v}°C`, + }, + { + field: 'waterTempAt3M', + label: 'Water Temperature at 3M', + formatter: (v) => `${v}°C`, + }, + { + field: 'waterTempAt10M', + label: 'Water Temperature at 10M', + formatter: (v) => `${v}°C`, + }, + { field: 'overallAnthroImpact', label: 'Overall Anthropogenic Impact' }, + { field: 'siltation', label: 'Siltation' }, + { field: 'dynamiteFishing', label: 'Dynamite Fishing' }, + { field: 'poisonFishing', label: 'Poison Fishing' }, + { field: 'aquariumFishCollection', label: 'Aquarium Fish Collection' }, + { + field: 'harvestOfInvertsForFood', + label: 'Harvest of Invertebrates for Food', + }, + { + field: 'harvestOfInvertsForCurio', + label: 'Harvest of Invertebrates for Curio', + }, + { field: 'touristDivingSnorkeling', label: 'Tourist Diving/Snorkeling' }, + { field: 'sewagePollution', label: 'Sewage Pollution' }, + { field: 'industrialPollution', label: 'Industrial Pollution' }, + { field: 'commercialFishing', label: 'Commercial Fishing' }, + { field: 'liveFoodFishing', label: 'Live Food Fishing' }, + { field: 'artisinalRecreational', label: 'Artisanal/Recreational Fishing' }, + { field: 'yachts', label: 'Yachts' }, + { field: 'isSiteProtected', label: 'Is Site Protected?' }, + { field: 'isProtectionEnforced', label: 'Is Protection Enforced?' }, + { field: 'levelOfPoaching', label: 'Level of Poaching' }, +// eslint-disable-next-line prettier/prettier +] satisfies Array>; + +const ReefCheckSurveyDetailsComponent = ({ + classes, +}: ReefCheckSurveyDetailsProps) => { + const { survey, loading, error } = useSelector(reefCheckSurveySelector); + const date = survey?.date ? new Date(survey.date).toLocaleDateString() : ''; + + if (error) { + return null; + } + return ( + + {date} REEF CHECK SURVEY DATA + + {surveyFields.map(({ field, label, formatter }) => ( + + {label} + + {loading || !survey ? ( + + ) : ( + formatter?.(survey[field]) ?? survey[field] ?? '' + )} + + + ))} + + + ); +}; + +const styles = (theme: Theme) => + createStyles({ + paper: { + padding: 16, + color: theme.palette.text.secondary, + }, + title: { + borderBottom: '1px solid black', + }, + skeleton: { + backgroundColor: '#E2E2E2', + }, + container: { + display: 'inline-block', + width: '100%', + borderTop: '1px solid #E0E0E0', + borderLeft: '1px solid #E0E0E0', + columnGap: 0, + columnCount: 3, + [theme.breakpoints.down('md')]: { + columnCount: 2, + }, + [theme.breakpoints.down('sm')]: { + columnCount: 1, + }, + }, + item: { + display: 'flex', + flexWrap: 'nowrap', + }, + label: { + backgroundColor: '#FAFAFA', + flexBasis: 260, + padding: 8, + borderBottom: '1px solid #E0E0E0', + borderRight: '1px solid #E0E0E0', + }, + value: { + padding: 8, + borderBottom: '1px solid #E0E0E0', + borderRight: '1px solid #E0E0E0', + flex: 1, + }, + }); + +type ReefCheckSurveyDetailsProps = WithStyles; +export const ReefCheckSurveyDetails = withStyles(styles)( + ReefCheckSurveyDetailsComponent, +); diff --git a/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/ReefCheckSurveyOrganismsTable.tsx b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/ReefCheckSurveyOrganismsTable.tsx new file mode 100644 index 000000000..1b1317314 --- /dev/null +++ b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/ReefCheckSurveyOrganismsTable.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { reefCheckSurveySelector } from 'store/ReefCheckSurveys/reefCheckSurveySlice'; +import { ReefCheckOrganism } from 'store/ReefCheckSurveys/types'; +import { ColumnDef, ReefCheckSurveyTable } from './ReefCheckSurveyTable'; +import { segmentsTotalSortComparator } from './utils'; + +type ReefCheckSurveyOrganismsTableProps = { + columns: ColumnDef[]; + title: string; + description?: string; + filter?: (organism: ReefCheckOrganism) => boolean; +}; + +export const ReefCheckSurveyOrganismsTable = ({ + columns, + title, + description = '', + filter = () => true, +}: ReefCheckSurveyOrganismsTableProps) => { + const { survey, loading, error } = useSelector(reefCheckSurveySelector); + const rows = + // eslint-disable-next-line fp/no-mutating-methods + survey?.organisms.filter(filter).sort(segmentsTotalSortComparator) ?? []; + + if (error) { + return null; + } + + return ( + + data={rows} + columns={columns} + title={title} + loading={loading} + description={description} + /> + ); +}; diff --git a/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/ReefCheckSurveySubstratesTable.tsx b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/ReefCheckSurveySubstratesTable.tsx new file mode 100644 index 000000000..ac2eb8b35 --- /dev/null +++ b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/ReefCheckSurveySubstratesTable.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { reefCheckSurveySelector } from 'store/ReefCheckSurveys/reefCheckSurveySlice'; +import { ReefCheckSubstrate } from 'store/ReefCheckSurveys/types'; +import { ColumnDef, ReefCheckSurveyTable } from './ReefCheckSurveyTable'; +import { segmentsTotalSortComparator } from './utils'; + +type ReefCheckSurveySubstratesTableProps = { + columns: ColumnDef[]; + title: string; + description?: string; + filter?: (organism: ReefCheckSubstrate) => boolean; +}; + +export const ReefCheckSurveySubstrates = ({ + columns, + title, + description = '', + filter = () => true, +}: ReefCheckSurveySubstratesTableProps) => { + const { survey, loading, error } = useSelector(reefCheckSurveySelector); + const rows = + // eslint-disable-next-line fp/no-mutating-methods + survey?.substrates.filter(filter).sort(segmentsTotalSortComparator) ?? []; + + if (error) { + return null; + } + return ( + + data={rows} + columns={columns} + title={title} + loading={loading} + description={description} + /> + ); +}; diff --git a/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/ReefCheckSurveySummary.tsx b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/ReefCheckSurveySummary.tsx new file mode 100644 index 000000000..f70f1376d --- /dev/null +++ b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/ReefCheckSurveySummary.tsx @@ -0,0 +1,157 @@ +import { Box, Link, Paper, Theme, Typography, Skeleton } from '@mui/material'; +import { createStyles, WithStyles } from '@mui/styles'; +import withStyles from '@mui/styles/withStyles'; + +import React from 'react'; +import { useSelector } from 'react-redux'; +import cls from 'classnames'; +import { reefCheckSurveySelector } from 'store/ReefCheckSurveys/reefCheckSurveySlice'; +import ObservationBox from 'routes/Surveys/View/ObservationBox'; +import reefCheckLogo from '../../../assets/img/reef-check.png'; + +export const ReefCheckSurveySummaryComponent = ({ + classes, +}: ReefCheckSurveySummaryProps) => { + const { survey, loading, error } = useSelector(reefCheckSurveySelector); + + if (error) { + return null; + } + return ( + + + Reef check Survey Data + + {loading ? ( + + ) : ( + formatDate(survey?.date ?? '') + )} + + Reef Check Logo + + + + {loading ? ( + + ) : ( + survey?.reefCheckSite?.reefName + )} + + + {loading ? ( + + ) : ( + survey?.reefCheckSite?.region + )} + + + + + Data is collected along four 5-meter-wide by 20-meter-long segments of + a 100-meter transect line for a total survey area of 400m². Each + segment has an area of 100m² and is labelled as s1, s2, s3, or s4. + + + Learn more about the data and how it’s collected + + + + {loading ? ( + + ) : ( + + )} + {/* TODO: Add back when functionality is finalized + */} + + + ); +}; + +export const formatDate = (dateStr: string): string => { + const date = new Date(dateStr); + return date.toLocaleTimeString([], { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +}; + +const styles = (theme: Theme) => + createStyles({ + paper: { + padding: 12, + color: theme.palette.text.secondary, + display: 'flex', + flexWrap: 'wrap', + gap: 32, + }, + skeleton: { + backgroundColor: '#E2E2E2', + }, + surveySource: { + padding: 12, + borderRadius: 8, + border: '1px solid #E0E0E0', + [theme.breakpoints.down('xs')]: { + width: '100%', + }, + }, + note: { + flex: '1 1 300px', + padding: 12, + borderRadius: 8, + backgroundColor: '#F5F6F6', + }, + observationBox: { + gap: 32, + }, + columnSpaceBetween: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + }, + columnCenter: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + }, + }); + +type ReefCheckSurveySummaryProps = WithStyles; + +export const ReefCheckSurveySummary = withStyles(styles)( + ReefCheckSurveySummaryComponent, +); diff --git a/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/ReefCheckSurveyTable/ReefCheckSurveyTable.test.tsx b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/ReefCheckSurveyTable/ReefCheckSurveyTable.test.tsx new file mode 100644 index 000000000..b9f0f76cf --- /dev/null +++ b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/ReefCheckSurveyTable/ReefCheckSurveyTable.test.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { ThemeProvider } from '@mui/material'; +import theme from 'layout/App/theme'; +import { ColumnDef, ReefCheckSurveyTable } from '.'; + +jest.mock('@mui/material/Skeleton', () => ({ + ...jest.requireActual(`@mui/material/Skeleton`), + __esModule: true, + Skeleton: 'mock-skeleton', + default: 'mock-skeleton', +})); + +describe('ReefCheckSurveyTable', () => { + type MockDataItem = { + id: number; + name: string; + age: string; + }; + const mockTitle = 'title'; + const mockDescription = 'description'; + const mockColumns = [ + { header: 'Name', field: 'name' }, + { header: 'Age', field: 'age' }, + ] as ColumnDef[]; + const mockData = [ + { id: 1, name: 'Joe', age: '30' }, + { id: 2, name: 'Doe', age: '25' }, + ]; + + function renderReefCheckSurveyTable({ + loading, + data = mockData, + }: { loading?: boolean; data?: MockDataItem[] } = {}) { + return render( + + + , + ); + } + + it('should render correctly', () => { + const { getByText, container } = renderReefCheckSurveyTable(); + expect(getByText(mockTitle)).toBeInTheDocument(); + expect(getByText(mockDescription)).toBeInTheDocument(); + + const rows = container.querySelectorAll('mock-tablerow'); + expect(rows.length).toBe(3); // 1 header + 2 data + expect( + [...container.querySelectorAll('mock-tablecell').values()].map( + (el) => el.textContent, + ), + ).toEqual(['Name', 'Age', 'Joe', '30', 'Doe', '25']); + }); + + it('should render skeleton rows when loading', () => { + const { container } = renderReefCheckSurveyTable({ + loading: true, + data: [], + }); + const rows = container.querySelectorAll('mock-tablerow'); + expect(rows.length).toBe(4); // 1 header + 3 skeleton rows + rows.forEach((row, i) => { + if (i === 0) { + // skip header + return; + } + const cells = row.querySelectorAll('mock-skeleton'); + expect(cells.length).toBe(mockColumns.length); + }); + }); +}); diff --git a/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/ReefCheckSurveyTable/ReefCheckSurveyTable.tsx b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/ReefCheckSurveyTable/ReefCheckSurveyTable.tsx new file mode 100644 index 000000000..509d1d686 --- /dev/null +++ b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/ReefCheckSurveyTable/ReefCheckSurveyTable.tsx @@ -0,0 +1,122 @@ +import { + TableContainer, + Paper, + Typography, + Table, + TableHead, + TableRow, + TableCell, + TableBody, + TableCellProps, + Theme, + Skeleton, +} from '@mui/material'; +import withStyles from '@mui/styles/withStyles'; +import { createStyles, WithStyles } from '@mui/styles'; +import { times } from 'lodash'; +import React from 'react'; + +export type ColumnDef = { + field: keyof T | ((row: T) => string | number); + header: string; +} & TableCellProps; + +type ObjectWithId = { + id: string | number; +}; + +type ReefCheckSurveyTableIncomingProps = { + data: T[]; + columns: ColumnDef[]; + title: string; + loading?: boolean | null; + description?: string; +}; + +const ReefCheckSurveyTableComponent = ({ + data, + columns, + title, + loading, + description = '', + classes, +}: ReefCheckSurveyTableProps) => { + return ( + <> + + {title} + + {description} + + + + + {columns.map(({ header, field, ...props }) => ( + + {header} + + ))} + + + + {loading && + times(3).map((index) => ( + + {columns.map(({ header, field, ...props }) => ( + + + + ))} + + ))} + {!loading && + data.map((row) => ( + + {columns.map(({ header, field, ...props }) => { + const value = + typeof field === 'function' ? field(row) : row[field]; + return ( + + {value as React.ReactNode} + + ); + })} + + ))} + +
+
+ + ); +}; + +const styles = (theme: Theme) => + createStyles({ + paper: { + padding: 16, + color: theme.palette.text.secondary, + }, + skeleton: { + backgroundColor: '#E2E2E2', + }, + title: { + textTransform: 'uppercase', + }, + description: { + margin: '8px 0', + }, + header: { + backgroundColor: '#FAFAFA', + borderBottom: '1px solid black', + borderTop: '1px solid rgba(224, 224, 224, 1)', + }, + }); + +type ReefCheckSurveyTableProps = + ReefCheckSurveyTableIncomingProps & WithStyles; + +export const ReefCheckSurveyTable = withStyles(styles)( + ReefCheckSurveyTableComponent, +) as ( + props: ReefCheckSurveyTableIncomingProps, +) => React.ReactElement; diff --git a/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/ReefCheckSurveyTable/index.ts b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/ReefCheckSurveyTable/index.ts new file mode 100644 index 000000000..42904bd53 --- /dev/null +++ b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/ReefCheckSurveyTable/index.ts @@ -0,0 +1 @@ +export * from './ReefCheckSurveyTable'; diff --git a/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/colDefs/bleaching.colDef.ts b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/colDefs/bleaching.colDef.ts new file mode 100644 index 000000000..c908ae10f --- /dev/null +++ b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/colDefs/bleaching.colDef.ts @@ -0,0 +1,37 @@ +import { ReefCheckOrganism } from 'store/ReefCheckSurveys/types'; +import { mean } from 'lodash'; +import type { ColumnDef } from '../ReefCheckSurveyTable'; + +export const bleachingColumns: ColumnDef[] = [ + { field: 'organism', header: 'Bleaching and Coral Diseases Type' }, + { + field: ({ s1 }) => `${s1}%`, + header: 's1 (0-20m)', + align: 'center', + width: 200, + }, + { + field: ({ s2 }) => `${s2}%`, + header: 's2 (25-45m)', + align: 'center', + width: 200, + }, + { + field: ({ s3 }) => `${s3}%`, + header: 's3 (50-70m)', + align: 'center', + width: 200, + }, + { + field: ({ s4 }) => `${s4}%`, + header: 's4 (75-95m)', + align: 'center', + width: 200, + }, + { + field: (row) => `${mean([row.s1, row.s2, row.s3, row.s4])}%`, + header: 'Average % per 100m²', + align: 'center', + width: 200, + }, +]; diff --git a/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/colDefs/fish.colDef.ts b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/colDefs/fish.colDef.ts new file mode 100644 index 000000000..21770da2c --- /dev/null +++ b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/colDefs/fish.colDef.ts @@ -0,0 +1,23 @@ +import { ReefCheckOrganism } from 'store/ReefCheckSurveys/types'; +import { mean } from 'lodash'; +import type { ColumnDef } from '../ReefCheckSurveyTable'; + +export const fishColumns: ColumnDef[] = [ + { field: 'organism', header: 'Fish Type' }, + { field: 's1', header: 's1 (0-20m)', align: 'center', width: 200 }, + { field: 's2', header: 's2 (25-45m)', align: 'center', width: 200 }, + { field: 's3', header: 's3 (50-70m)', align: 'center', width: 200 }, + { field: 's4', header: 's4 (75-95m)', align: 'center', width: 200 }, + { + field: (row) => row.s1 + row.s2 + row.s3 + row.s4, + header: 'Total', + align: 'center', + width: 200, + }, + { + field: (row) => mean([row.s1, row.s2, row.s3, row.s4]), + header: 'Average per 100m²', + align: 'center', + width: 200, + }, +]; diff --git a/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/colDefs/impact.colDef.ts b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/colDefs/impact.colDef.ts new file mode 100644 index 000000000..43a0b4f60 --- /dev/null +++ b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/colDefs/impact.colDef.ts @@ -0,0 +1,17 @@ +import { mean } from 'lodash'; +import { ReefCheckOrganism } from 'store/ReefCheckSurveys/types'; +import type { ColumnDef } from '../ReefCheckSurveyTable'; + +export const impactColumns: ColumnDef[] = [ + { field: 'organism', header: 'Impact Type' }, + { field: 's1', header: 's1 (0-20m)', align: 'center', width: 200 }, + { field: 's2', header: 's2 (25-45m)', align: 'center', width: 200 }, + { field: 's3', header: 's3 (50-70m)', align: 'center', width: 200 }, + { field: 's4', header: 's4 (75-95m)', align: 'center', width: 200 }, + { + field: (row) => mean([row.s1, row.s2, row.s3, row.s4]), + header: 'Average per 100m²', + align: 'center', + width: 200, + }, +]; diff --git a/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/colDefs/invertables.colDef.ts b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/colDefs/invertables.colDef.ts new file mode 100644 index 000000000..1953f784d --- /dev/null +++ b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/colDefs/invertables.colDef.ts @@ -0,0 +1,23 @@ +import { ReefCheckOrganism } from 'store/ReefCheckSurveys/types'; +import { mean } from 'lodash'; +import type { ColumnDef } from '../ReefCheckSurveyTable'; + +export const invertebratesColumns: ColumnDef[] = [ + { field: 'organism', header: 'Invertebrate Type' }, + { field: 's1', header: 's1 (0-20m)', align: 'center', width: 200 }, + { field: 's2', header: 's2 (25-45m)', align: 'center', width: 200 }, + { field: 's3', header: 's3 (50-70m)', align: 'center', width: 200 }, + { field: 's4', header: 's4 (75-95m)', align: 'center', width: 200 }, + { + field: (row) => row.s1 + row.s2 + row.s3 + row.s4, + header: 'Total', + align: 'center', + width: 200, + }, + { + field: (row) => mean([row.s1, row.s2, row.s3, row.s4]), + header: 'Average per 100m²', + align: 'center', + width: 200, + }, +]; diff --git a/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/colDefs/rareAnimals.colDef.ts b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/colDefs/rareAnimals.colDef.ts new file mode 100644 index 000000000..0eebb6209 --- /dev/null +++ b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/colDefs/rareAnimals.colDef.ts @@ -0,0 +1,23 @@ +import { ReefCheckOrganism } from 'store/ReefCheckSurveys/types'; +import { mean } from 'lodash'; +import type { ColumnDef } from '../ReefCheckSurveyTable'; + +export const rareAnimalsColumns: ColumnDef[] = [ + { field: 'organism', header: 'Rare Animals Type' }, + { field: 's1', header: 's1 (0-20m)', align: 'center', width: 200 }, + { field: 's2', header: 's2 (25-45m)', align: 'center', width: 200 }, + { field: 's3', header: 's3 (50-70m)', align: 'center', width: 200 }, + { field: 's4', header: 's4 (75-95m)', align: 'center', width: 200 }, + { + field: (row) => row.s1 + row.s2 + row.s3 + row.s4, + header: 'Total', + align: 'center', + width: 200, + }, + { + field: (row) => mean([row.s1, row.s2, row.s3, row.s4]), + header: 'Average per 100m²', + align: 'center', + width: 200, + }, +]; diff --git a/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/colDefs/reefStructure.colDef.ts b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/colDefs/reefStructure.colDef.ts new file mode 100644 index 000000000..6af9d9183 --- /dev/null +++ b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/colDefs/reefStructure.colDef.ts @@ -0,0 +1,56 @@ +import { mean } from 'lodash'; +import { ReefCheckSubstrate } from 'store/ReefCheckSurveys/types'; +import type { ColumnDef } from '../ReefCheckSurveyTable'; + +const substrateCodesMap: Record = { + HC: 'Hard Coral', + 'HC/B': 'Hard Coral Bleaching', + 'HC/D': 'Hard Coral Disease', + SC: 'Soft Coral', + RKC: 'Recently Killed Coral', + NIA: 'Nutrient indicator Algea', + FS: 'Fleshy Seaweed', + SP: 'Sponge', + RC: 'Rock', + RB: 'Rubble', + SD: 'Sand', + SI: 'Silt/Clay', + OT: 'Other', +}; + +export const reefStructureColumns: ColumnDef[] = [ + { + field: ({ substrateCode }) => substrateCodesMap[substrateCode], + header: 'Reef Structure and Composition Type', + }, + { + field: ({ s1 }) => `${s1}%`, + header: 's1 (0-20m)', + align: 'center', + width: 200, + }, + { + field: ({ s2 }) => `${s2}%`, + header: 's2 (25-45m)', + align: 'center', + width: 200, + }, + { + field: ({ s3 }) => `${s3}%`, + header: 's3 (50-70m)', + align: 'center', + width: 200, + }, + { + field: ({ s4 }) => `${s4}%`, + header: 's4 (75-95m)', + align: 'center', + width: 200, + }, + { + field: (row) => `${mean([row.s1, row.s2, row.s3, row.s4])}%`, + header: 'Average % per 100m²', + align: 'center', + width: 200, + }, +]; diff --git a/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/index.test.tsx b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/index.test.tsx new file mode 100644 index 000000000..7e67f3079 --- /dev/null +++ b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/index.test.tsx @@ -0,0 +1,183 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { ThemeProvider } from '@mui/material/styles'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import configureStore from 'redux-mock-store'; +import { RootState } from 'store/configure'; +import { mockSite } from 'mocks/mockSite'; +import { mockReefCheckSurvey } from 'mocks/mockReefCheckSurvey'; +import { SelectedSiteState } from 'store/Sites/types'; +import theme from 'layout/App/theme'; +import { formatDate } from './ReefCheckSurveySummary'; +import { ReefCheckSurveyViewPage } from '.'; +import * as organismsTableModule from './ReefCheckSurveyOrganismsTable'; +import * as substratesModule from './ReefCheckSurveySubstratesTable'; + +jest.mock('common/NavBar', () => 'Mock-NavBar'); + +describe('ReefCheckSurveyViewPage', () => { + const mockStore = configureStore([]); + const scrollToSpy = jest + .spyOn(window, 'scrollTo') + .mockImplementation(() => null); + const reefCheckSurveyOrganismsTableSpy = jest.spyOn( + organismsTableModule, + 'ReefCheckSurveyOrganismsTable', + ); + + const reefCheckSurveySubstratesTableSpy = jest.spyOn( + substratesModule, + 'ReefCheckSurveySubstrates', + ); + + function renderReefCheckSurveyViewPage(error?: string) { + const store = mockStore({ + selectedSite: { details: mockSite, error } as SelectedSiteState, + reefCheckSurvey: { + loading: false, + survey: mockReefCheckSurvey, + }, + } as Partial); + const mockSiteId = 1; + const mockSurveyId = 1; + + store.dispatch = jest.fn(); + const renderResult = render( + + + + + } + /> + + + + , + ); + return { ...renderResult, store }; + } + + afterAll(() => { + reefCheckSurveyOrganismsTableSpy.mockRestore(); + reefCheckSurveySubstratesTableSpy.mockRestore(); + scrollToSpy.mockRestore(); + }); + + it('should dispatch reefCheckSurveyGetRequest and siteRequest on mount', () => { + const { store } = renderReefCheckSurveyViewPage(); + + expect(store.dispatch).toHaveBeenCalledTimes(2); + }); + + it('should render Not found if error is present in the store', () => { + const { getByAltText } = renderReefCheckSurveyViewPage('error'); + + expect(getByAltText('404 Not Found')).toBeInTheDocument(); + }); + + it('should render the NavBar component', () => { + const { container } = renderReefCheckSurveyViewPage(); + + expect(container.querySelector('mock-navbar')).toBeInTheDocument(); + }); + + it('should render a Button component with a link to the site page', () => { + const { getByRole } = renderReefCheckSurveyViewPage(); + + expect(getByRole('link', { name: 'Back to site' })).toHaveAttribute( + 'href', + `/sites/${mockSite.id}`, + ); + }); + + describe('ReefCheckSurveySummary', () => { + it('should render summary', () => { + const { getByText, getByAltText } = renderReefCheckSurveyViewPage(); + const { reefName, region } = mockReefCheckSurvey.reefCheckSite ?? {}; + + expect( + getByText(formatDate(mockReefCheckSurvey.date)), + ).toBeInTheDocument(); + expect(getByAltText('Reef Check Logo')).toBeInTheDocument(); + expect(getByText(reefName ?? '')).toBeInTheDocument(); + expect(getByText(region ?? '')).toBeInTheDocument(); + expect( + getByText('Learn more about the data and how it’s collected'), + ).toHaveAttribute( + 'href', + 'https://www.reefcheck.org/tropical-program/tropical-monitoring-instruction/', + ); + expect(getByText('SATELLITE OBSERVATION')).toBeInTheDocument(); + expect( + getByText(`${mockReefCheckSurvey.satelliteTemperature} °C`), + ).toBeInTheDocument(); + }); + + it.todo('should request to download data when button is clicked'); + }); + + describe('ReefCheckSurveyDetails', () => { + it('should render survey details', () => { + const { getByText } = renderReefCheckSurveyViewPage(); + const { date } = mockReefCheckSurvey; + const displayDate = new Date(date).toLocaleDateString(); + + expect( + getByText(`${displayDate} REEF CHECK SURVEY DATA`), + ).toBeInTheDocument(); + }); + }); + + describe('ReefCheckSurveyOrganismsTable', () => { + beforeAll(() => renderReefCheckSurveyViewPage()); + + it('should render fish table', () => { + expect(reefCheckSurveyOrganismsTableSpy).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Fish' }), + expect.anything(), + ); + }); + + it('should render invertebrate table', () => { + expect(reefCheckSurveyOrganismsTableSpy).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Invertebrate' }), + expect.anything(), + ); + }); + + it('should render impact table', () => { + expect(reefCheckSurveyOrganismsTableSpy).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Impact' }), + expect.anything(), + ); + }); + + it('should render bleaching and coral diseases table', () => { + expect(reefCheckSurveyOrganismsTableSpy).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Bleaching and Coral Diseases' }), + expect.anything(), + ); + }); + + it('should render rare animal table', () => { + expect(reefCheckSurveyOrganismsTableSpy).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Rare Animal' }), + expect.anything(), + ); + }); + + it('should render reef structure and composition table', () => { + expect(reefCheckSurveySubstratesTableSpy).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Reef Structure and Composition' }), + expect.anything(), + ); + }); + }); +}); diff --git a/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/index.tsx b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/index.tsx new file mode 100644 index 000000000..271753c4a --- /dev/null +++ b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/index.tsx @@ -0,0 +1,121 @@ +import React, { useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { Link, useParams } from 'react-router-dom'; +import { Box, Button, Grid, Typography } from '@mui/material'; +import { ArrowBack } from '@mui/icons-material'; +import { reefCheckSurveyGetRequest } from 'store/ReefCheckSurveys/reefCheckSurveySlice'; +import { siteErrorSelector, siteRequest } from 'store/Sites/selectedSiteSlice'; +import NavBar from 'common/NavBar'; +import NotFound from 'routes/NotFound'; +import { reefCheckImpactRows } from 'store/ReefCheckSurveys'; +import { fishColumns } from './colDefs/fish.colDef'; +import { invertebratesColumns } from './colDefs/invertables.colDef'; +import { impactColumns } from './colDefs/impact.colDef'; +import { rareAnimalsColumns } from './colDefs/rareAnimals.colDef'; +import { bleachingColumns } from './colDefs/bleaching.colDef'; +import { reefStructureColumns } from './colDefs/reefStructure.colDef'; +import { ReefCheckSurveyOrganismsTable } from './ReefCheckSurveyOrganismsTable'; +import { ReefCheckSurveySummary } from './ReefCheckSurveySummary'; +import { ReefCheckSurveyDetails } from './ReefCheckSurveyDetails'; +import { ReefCheckSurveySubstrates } from './ReefCheckSurveySubstratesTable'; + +export const ReefCheckSurveyViewPage = () => { + const { id: siteId = '', sid: surveyId = '' } = + useParams<{ id: string; sid: string }>(); + const error = useSelector(siteErrorSelector); + const dispatch = useDispatch(); + + useEffect(() => { + window.scrollTo({ top: 0 }); + dispatch(reefCheckSurveyGetRequest({ siteId, surveyId })); + dispatch(siteRequest(siteId)); + }, [dispatch, siteId, surveyId]); + + if (error) { + return ( + <> + + + + ); + } + return ( + <> + + + + + + + + + + + + + + + + + + row.type === 'Fish'} + /> + + + row.type === 'Invertebrate'} + /> + + + + row.type === 'Impact' && + reefCheckImpactRows.includes(row.organism) + } + /> + + + + row.type === 'Impact' && + !reefCheckImpactRows.includes(row.organism) + } + /> + + + row.type === 'Rare Animal'} + /> + + + + + ); +}; diff --git a/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/utils/index.ts b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/utils/index.ts new file mode 100644 index 000000000..85b8ca4d2 --- /dev/null +++ b/packages/website/src/routes/SiteRoutes/ReefCheckSurveys/utils/index.ts @@ -0,0 +1,13 @@ +type SegmentsEntity = { + s1: number; + s2: number; + s3: number; + s4: number; +}; + +export function segmentsTotalSortComparator( + a: T, + b: T, +) { + return b.s1 + b.s2 + b.s3 + b.s4 - (a.s1 + a.s2 + a.s3 + a.s4); +} diff --git a/packages/website/src/routes/SiteRoutes/Site/__snapshots__/index.test.tsx.snap b/packages/website/src/routes/SiteRoutes/Site/__snapshots__/index.test.tsx.snap index b74e947b7..e2bf2c104 100644 --- a/packages/website/src/routes/SiteRoutes/Site/__snapshots__/index.test.tsx.snap +++ b/packages/website/src/routes/SiteRoutes/Site/__snapshots__/index.test.tsx.snap @@ -2484,6 +2484,7 @@ exports[`Site Detail Page should render with given state from Redux store: snaps @@ -5611,6 +5612,7 @@ exports[`Site Detail Page should render with given state from Redux store: snaps diff --git a/packages/website/src/routes/SiteRoutes/Site/index.tsx b/packages/website/src/routes/SiteRoutes/Site/index.tsx index 1cf58df32..5336c2b95 100644 --- a/packages/website/src/routes/SiteRoutes/Site/index.tsx +++ b/packages/website/src/routes/SiteRoutes/Site/index.tsx @@ -37,6 +37,7 @@ import SiteDetails from 'common/SiteDetails'; import { localizedEndOfDay } from 'common/Chart/MultipleSensorsCharts/helpers'; import LoadingSkeleton from 'common/LoadingSkeleton'; import { DateTime } from 'luxon-extensions'; +import { reefCheckSurveysRequest } from 'store/ReefCheckSurveys'; import SiteInfo from './SiteInfo'; import NotFoundPage from '../../NotFound/index'; @@ -180,6 +181,13 @@ const Site = ({ classes }: SiteProps) => { }; }, [dispatch, siteId]); + // Fetch reef check surveys + useEffect(() => { + if (siteDetails?.reefCheckSite?.id) { + dispatch(reefCheckSurveysRequest(siteDetails.reefCheckSite.id)); + } + }, [dispatch, siteDetails?.reefCheckSite?.id]); + // Fetch time series data range for the site's closest survey point // once the survey points are successfully fetched useEffect(() => { diff --git a/packages/website/src/routes/SiteRoutes/SiteApplication/Form/__snapshots__/index.test.tsx.snap b/packages/website/src/routes/SiteRoutes/SiteApplication/Form/__snapshots__/index.test.tsx.snap index ef32800dc..c4e090959 100644 --- a/packages/website/src/routes/SiteRoutes/SiteApplication/Form/__snapshots__/index.test.tsx.snap +++ b/packages/website/src/routes/SiteRoutes/SiteApplication/Form/__snapshots__/index.test.tsx.snap @@ -479,12 +479,11 @@ exports[`renders as expected 1`] = `