diff --git a/.env.development b/.env.development index baa28a6c7..c7dc91cdc 100644 --- a/.env.development +++ b/.env.development @@ -13,3 +13,4 @@ REACT_APP_ENABLE_MESSAGING=true REACT_APP_ENABLE_PAYMENTS=true REACT_APP_REPORTING_ENABLED=true REACT_APP_ENABLE_REGIONS=true +REACT_APP_ENABLE_STAKEHOLDERS=true diff --git a/src/common/variables.js b/src/common/variables.js index c3e9f258b..a44d9f6d3 100644 --- a/src/common/variables.js +++ b/src/common/variables.js @@ -1,4 +1,5 @@ export const drawerWidth = 240; +export const SIDE_PANEL_WIDTH = 315; export const selectedHighlightColor = '#0af'; export const documentTitle = 'Treetracker Admin by Greenstand'; export const verificationStates = { diff --git a/src/components/CaptureEdit.js b/src/components/CaptureEdit.js new file mode 100644 index 000000000..f1df8052b --- /dev/null +++ b/src/components/CaptureEdit.js @@ -0,0 +1,337 @@ +import React, { useState } from 'react'; +import { makeStyles } from '@material-ui/core/styles'; +import Typography from '@material-ui/core/Typography'; +import { Button } from '@material-ui/core'; +import Grid from '@material-ui/core/Grid'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Drawer from '@material-ui/core/Drawer'; +import RadioGroup from '@material-ui/core/RadioGroup'; +import Radio from '@material-ui/core/Radio'; +// import Tabs from '@material-ui/core/Tabs'; +// import Tab from '@material-ui/core/Tab'; +import TextField from '@material-ui/core/TextField'; +import Checkbox from '@material-ui/core/Checkbox'; +import Species from './Species'; +import CaptureTags from './CaptureTags'; + +const SIDE_PANEL_WIDTH = 315; + +const useStyles = makeStyles((theme) => ({ + sidePanel: { + width: SIDE_PANEL_WIDTH, + }, + drawerPaper: { + width: SIDE_PANEL_WIDTH, + }, + sidePanelContainer: { + padding: theme.spacing(2), + flexWrap: 'nowrap', + }, + sidePanelItem: { + marginTop: theme.spacing(1), + }, + radioGroup: { + flexDirection: 'row', + }, + bottomLine: { + borderBottom: '1px solid lightgray', + }, + sidePanelSubmitButton: { + width: '128px', + }, +})); + +function CaptureEdit(props) { + const DEFAULT_SWITCH_APPROVE = 0; + const DEFAULT_MORPHOLOGY = 'seedling'; + const DEFAULT_AGE = 'new_tree'; + const DEFAULT_CAPTURE_APPROVAL_TAG = 'simple_leaf'; + const DEFAULT_REJECTION_REASON = 'not_tree'; + + const classes = useStyles(props); + const [switchApprove, setSwitchApprove] = useState(DEFAULT_SWITCH_APPROVE); + const [morphology, setMorphology] = useState(DEFAULT_MORPHOLOGY); + const [age, setAge] = useState(DEFAULT_AGE); + const [captureApprovalTag, setCaptureApprovalTag] = useState( + DEFAULT_CAPTURE_APPROVAL_TAG + ); + const [rejectionReason, setRejectionReason] = useState( + DEFAULT_REJECTION_REASON + ); + const [rememberSelection, setRememberSelection] = useState(false); + + function resetSelection() { + setSwitchApprove(DEFAULT_SWITCH_APPROVE); + setMorphology(DEFAULT_MORPHOLOGY); + setAge(DEFAULT_AGE); + setCaptureApprovalTag(DEFAULT_CAPTURE_APPROVAL_TAG); + setRejectionReason(DEFAULT_REJECTION_REASON); + } + + async function handleSubmit() { + const approveAction = + switchApprove === 0 + ? { + isApproved: true, + morphology, + age, + captureApprovalTag, + rememberSelection, + } + : { + isApproved: false, + rejectionReason, + rememberSelection, + }; + await props.onSubmit(approveAction); + if (!rememberSelection) { + resetSelection(); + } + } + + return ( + + + + {/* + setSwitchApprove(0)} + /> + setSwitchApprove(1)} + /> + */} + + + + + Morphology + + + + + setMorphology('seedling')} + control={} + label="Seedling" + /> + } + onClick={() => setMorphology('direct_seedling')} + label="Direct seeding" + /> + setMorphology('fmnr')} + value="fmnr" + control={} + label="Pruned/tied (FMNR)" + /> + + + + Age + + setAge('new_tree')} + value="new_tree" + control={} + label="New tree(s)" + /> + setAge('over_two_years')} + value="over_two_years" + control={} + label="> 2 years old" + /> + + + + + Species (if known) + + + + + + Additional tags + + +
+
+ + setCaptureApprovalTag('simple_leaf')} + value="simple_leaf" + control={} + label="Simple leaf" + /> + setCaptureApprovalTag('complex_leaf')} + value="complex_leaf" + control={} + label="Complex leaf" + /> + setCaptureApprovalTag('acacia_like')} + value="acacia_like" + control={} + label="Acacia-like" + /> + setCaptureApprovalTag('conifer')} + value="conifer" + control={} + label="Conifer" + /> + setCaptureApprovalTag('fruit')} + value="fruit" + control={} + label="Fruit" + /> + setCaptureApprovalTag('mangrove')} + value="mangrove" + control={} + label="Mangrove" + /> + setCaptureApprovalTag('palm')} + value="palm" + control={} + label="Palm" + /> + setCaptureApprovalTag('timber')} + value="timber" + control={} + label="Timber" + /> + +
+ + {/* {switchApprove === 1 && ( + <> + + setRejectionReason('not_tree')} + value="not_tree" + control={} + label="Not a tree" + /> + setRejectionReason('unapproved_tree')} + value="unapproved_tree" + control={} + label="Not an approved tree" + /> + setRejectionReason('blurry_image')} + value="blurry_image" + control={} + label="Blurry photo" + /> + setRejectionReason('dead')} + value="dead" + control={} + label="Dead" + /> + setRejectionReason('duplicate_image')} + value="duplicate_image" + control={} + label="Duplicate photo" + /> + setRejectionReason('flag_user')} + value="flag_user" + control={} + label="Flag user!" + /> + setRejectionReason('needs_contact_or_review')} + value="needs_contact_or_review" + control={} + label="Flag capture for contact/review" + /> + + + + Additional tags + + + + + )} */} +
+ {/*Hidden until functionality is implemented. Issuer: https://github.com/Greenstand/treetracker-admin/issues/371*/} + {false && ( + + + + )} + {/* + */} + + + setRememberSelection(event.target.checked)} + name="remember" + color="secondary" + /> + } + label="Remember selection" + /> + +
+
+ ); +} + +export default CaptureEdit; diff --git a/src/components/CaptureFilter.js b/src/components/CaptureFilter.js index 6662b3be7..47a7e2c58 100644 --- a/src/components/CaptureFilter.js +++ b/src/components/CaptureFilter.js @@ -194,7 +194,12 @@ function Filter(props) { { active: true, approved: true }, { active: true, approved: false }, ]); - const filter = new FilterModel(); + const filter = new FilterModel({ + verifyStatus: [ + { active: true, approved: true }, + { active: true, approved: false }, + ], + }); props.onSubmit && props.onSubmit(filter); } diff --git a/src/components/CaptureFilterHeader.js b/src/components/CaptureFilterHeader.js index bd773e623..71e56a06c 100644 --- a/src/components/CaptureFilterHeader.js +++ b/src/components/CaptureFilterHeader.js @@ -1,5 +1,8 @@ import React, { useState, useContext } from 'react'; +// import { SIDE_PANEL_WIDTH } from '../common/variables.js'; import { Grid, Button } from '@material-ui/core'; +import ImageIcon from '@material-ui/icons/Image'; +import TableChartIcon from '@material-ui/icons/TableChart'; import { makeStyles } from '@material-ui/core/styles'; import Avatar from '@material-ui/core/Avatar'; import IconFilter from '@material-ui/icons/FilterList'; @@ -18,14 +21,14 @@ const useStyle = makeStyles((theme) => ({ }, })); -function CaptureFilterHeader(props) { - const classes = useStyle(props); - const capturesContext = useContext(CapturesContext); +function CaptureFilterHeader({ showGallery, setShowGallery }) { + const classes = useStyle(); + const { filter, updateFilter } = useContext(CapturesContext); const [isFilterShown, setFilterShown] = useState(true); - const numFilters = capturesContext.filter.countAppliedFilters(); + const numFilters = filter.countAppliedFilters(); const handleFilterSubmit = (filter) => { - capturesContext.updateFilter(filter); + updateFilter(filter); }; const handleFilterClick = () => { @@ -35,13 +38,34 @@ function CaptureFilterHeader(props) { return ( setShowGallery(false)} + startIcon={} + key={1} + disabled={!showGallery} + > + Table View + , + , + {imagePagination} + + + + + + {captureImageItems} + + + + {imagePagination} + + + + + {/* 0} + variant="persistent" + /> + {isApproveAllProcessing && ( + + + + )} + {isApproveAllProcessing && ( + +
+

PROCESSING ... {parseInt(complete)}% complete

+ +
+
+ )} */} + + ); +}; + +export default CaptureGallery; diff --git a/src/components/Captures/CaptureGallery.styles.js b/src/components/Captures/CaptureGallery.styles.js new file mode 100644 index 000000000..78b23908b --- /dev/null +++ b/src/components/Captures/CaptureGallery.styles.js @@ -0,0 +1,141 @@ +import { + selectedHighlightColor, + // SIDE_PANEL_WIDTH, +} from '../../common/variables.js'; +import { colorPrimary } from '../common/theme'; + +const styles = (theme) => ({ + wrapper: { + padding: theme.spacing(2, 8, 4, 8), + }, + cornerTable: { + margin: theme.spacing(1), + '&>*': { + display: 'inline-flex', + margin: theme.spacing(1, 1), + }, + }, + cardImg: { + width: '100%', + height: 'auto', + }, + cardTitle: { + color: '#f00', + }, + card: { + cursor: 'pointer', + '&:hover $cardMedia': { + transform: 'scale(1.04)', + }, + }, + cardCheckbox: { + position: 'absolute', + height: '1.2em', + width: '1.2em', + top: '0.2rem', + left: '0.3rem', + pointerEvents: 'none', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 1, + }, + cardSelected: { + backgroundColor: theme.palette.action.selected, + }, + cardContent: { + padding: '87% 0 0 0', + position: 'relative', + overflow: 'hidden', + }, + selected: { + border: `2px ${selectedHighlightColor} solid`, + }, + cardMedia: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + transform: 'scale(1)', + transition: theme.transitions.create('transform', { + easing: theme.transitions.easing.easeInOut, + duration: '0.2s', + }), + }, + cardWrapper: { + position: 'relative', + padding: theme.spacing(2), + }, + modal: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }, + modalContent: { + width: '40%', + backgroundColor: 'white', + color: colorPrimary, + padding: '20px', + borderRadius: '4px', + }, + placeholderCard: { + pointerEvents: 'none', + '& $card': { + background: '#eee', + '& *': { + opacity: 0, + }, + }, + }, + title: { + padding: theme.spacing(2, 8), + }, + snackbar: { + bottom: 20, + }, + snackbarContent: { + backgroundColor: theme.palette.action.active, + }, + cardActions: { + display: 'flex', + padding: theme.spacing(0, 2), + }, + button: { + marginRight: '8px', + }, + body: { + // width: `calc(100% - ${SIDE_PANEL_WIDTH}px)`, + display: 'flex', + flexDirection: 'column', + overflow: 'auto', + height: '100%', + }, + bodyInner: { + display: 'flex', + flexDirection: 'column', + }, + tooltip: { + maxWidth: 'none', + }, + MuiDialogActionsSpacing: { + paddingLeft: '16px', + paddingRight: '16px', + }, + sidePanelSubmitButton: { + width: '128px', + }, + mb: { + marginBottom: '1rem', + }, + activeFilters: { + width: theme.spacing(5), + height: theme.spacing(5), + marginLeft: '0.75rem', + backgroundColor: theme.palette.stats.green, + fontSize: 'smaller', + fontWeight: 'bold', + }, +}); + +export default styles; diff --git a/src/components/Captures/CaptureGallery.test.js b/src/components/Captures/CaptureGallery.test.js new file mode 100644 index 000000000..8d880f28a --- /dev/null +++ b/src/components/Captures/CaptureGallery.test.js @@ -0,0 +1,115 @@ +import React from 'react'; +import { act, render, screen, within, cleanup } from '@testing-library/react'; +import theme from '../common/theme'; +import { ThemeProvider } from '@material-ui/core/styles'; +import { CapturesContext } from '../../context/CapturesContext'; +import { SpeciesContext } from '../../context/SpeciesContext'; +import { TagsContext } from '../../context/TagsContext'; +import CaptureGallery from './CaptureGallery'; +import { + CAPTURES, + capturesValues, + tagsValues, + speciesValues, +} from '../tests/fixtures'; + +jest.setTimeout(7000); +jest.mock('../../api/treeTrackerApi'); + +describe('Captures', () => { + // mock captures context api methods + const getCaptures = () => { + return Promise.resolve(CAPTURES); + }; + const getCaptureCount = () => { + return Promise.resolve({ count: 4 }); + }; + + describe('with default values', () => { + beforeEach(async () => { + render( + + + + + {}} + handleShowGrowerDetail={() => {}} + /> + + + + + ); + + await act(() => getCaptures()); + await act(() => getCaptureCount()); + }); + + afterEach(cleanup); + + it('should show captures per page at top and bottom', () => { + const pageNums = screen.getAllByRole('button', { + name: /captures per page: 24/i, + }); + expect(pageNums).toHaveLength(2); + }); + + it('should show page # and capture count', () => { + const counts = Array.from( + document.querySelectorAll('.MuiTablePagination-caption') + ); + const arr = counts.map((count) => count.firstChild.textContent); + expect(arr[1]).toBe('1-4 of 4'); + }); + + it.skip('renders side panel', () => { + expect(screen.getByText(/approve/i)); + expect(screen.getByText(/reject/i)); + expect(screen.getByText(/morphology/i)); + expect(screen.getByText(/additional tags/i)); + }); + + it('renders captures cards', () => { + const cards = screen.getAllByTestId('capture-card'); + expect(cards).toHaveLength(4); + }); + + it('renders capture detail buttons for each card', () => { + const gallery = screen.getByTestId('captures-gallery'); + const captureDetailBtns = within(gallery).getAllByRole('button', { + name: /capture details/i, + }); + const arr = captureDetailBtns.map((link) => link.title); + expect(arr).toHaveLength(4); + }); + + it('renders grower detail buttons for each card', () => { + const gallery = screen.getByTestId('captures-gallery'); + const growerDetailBtns = within(gallery).getAllByRole('button', { + name: /grower details/i, + }); + const arr = growerDetailBtns.map((link) => link.title); + expect(arr).toHaveLength(4); + }); + + it('renders capture location buttons for each card', () => { + const gallery = screen.getByTestId('captures-gallery'); + const captureDetailBtns = within(gallery).getAllByRole('link', { + name: /capture location/i, + }); + const arr = captureDetailBtns.map((link) => link.title); + expect(arr).toHaveLength(4); + }); + + it('renders grower map buttons for each card', () => { + const gallery = screen.getByTestId('captures-gallery'); + const growerDetailBtns = within(gallery).getAllByRole('link', { + name: /grower map/i, + }); + const arr = growerDetailBtns.map((link) => link.title); + expect(arr).toHaveLength(4); + }); + }); +}); diff --git a/src/components/Captures/CaptureImageCard.js b/src/components/Captures/CaptureImageCard.js new file mode 100644 index 000000000..36c46e72e --- /dev/null +++ b/src/components/Captures/CaptureImageCard.js @@ -0,0 +1,157 @@ +import React from 'react'; +import clsx from 'clsx'; +import { makeStyles } from '@material-ui/core/styles'; +import Card from '@material-ui/core/Card'; +import CardContent from '@material-ui/core/CardContent'; +import Grid from '@material-ui/core/Grid'; +import Paper from '@material-ui/core/Paper'; +import IconButton from '@material-ui/core/IconButton'; +import { LocationOn } from '@material-ui/icons'; +import CheckIcon from '@material-ui/icons/Check'; +import Map from '@material-ui/icons/Map'; +import Nature from '@material-ui/icons/Nature'; +import Person from '@material-ui/icons/Person'; +import OptimizedImage from './../OptimizedImage'; + +const useStyles = makeStyles((theme) => ({ + card: { + cursor: 'pointer', + '&:hover $cardMedia': { + transform: 'scale(1.04)', + }, + }, + cardCheckbox: { + position: 'absolute', + height: '1.2em', + width: '1.2em', + top: '0.2rem', + left: '0.3rem', + pointerEvents: 'none', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 1, + }, + cardSelected: { + backgroundColor: theme.palette.action.selected, + }, + cardContent: { + padding: '87% 0 0 0', + position: 'relative', + overflow: 'hidden', + }, + cardMedia: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + transform: 'scale(1)', + transition: theme.transitions.create('transform', { + easing: theme.transitions.easing.easeInOut, + duration: '0.2s', + }), + }, + cardWrapper: { + position: 'relative', + padding: theme.spacing(2), + }, + placeholderCard: { + pointerEvents: 'none', + '& $card': { + background: '#eee', + '& *': { + opacity: 0, + }, + }, + }, + cardActions: { + display: 'flex', + padding: theme.spacing(0, 2), + }, +})); + +const CaptureImageCard = ({ + capture, + isCaptureSelected, + handleCaptureClick, + handleShowGrowerDetail, + handleShowCaptureDetail, + handleCapturePinClick, + handleGrowerMapClick, +}) => { + const classes = useStyles(); + + return ( + +
+ {isCaptureSelected(capture.id) && ( + + + + )} + handleCaptureClick(e, capture)} + id={`card_${capture.id}`} + className={classes.card} + elevation={capture.placeholder ? 0 : 3} + > + + + + + + + handleShowGrowerDetail(e, capture.planterId)} + aria-label={`Grower details`} + title={`Grower details`} + > + + + handleShowCaptureDetail(e, capture)} + aria-label={`Capture details`} + title={`Capture details`} + > + + + handleCapturePinClick(e, capture.id)} + aria-label={`Capture location`} + title={`Capture location`} + > + + + handleGrowerMapClick(e, capture.planterId)} + aria-label={`Grower map`} + title={`Grower map`} + > + + + + + +
+
+ ); +}; + +export default CaptureImageCard; diff --git a/src/components/Captures/CaptureTable.js b/src/components/Captures/CaptureTable.js index b77247a60..afe170672 100644 --- a/src/components/Captures/CaptureTable.js +++ b/src/components/Captures/CaptureTable.js @@ -1,8 +1,9 @@ import React, { useEffect, useState, useContext } from 'react'; import { + Button, Grid, + IconButton, Table, - Button, TableHead, TableBody, TableRow, @@ -11,8 +12,10 @@ import { TableSortLabel, Typography, } from '@material-ui/core'; -import { GetApp } from '@material-ui/icons'; +import { GetApp, Nature, Person } from '@material-ui/icons'; +import ImageIcon from '@material-ui/icons/Image'; import { getDateTimeStringLocale } from '../../common/locale'; +import { countToLocaleString } from '../../common/numbers'; import { getVerificationStatus } from '../../common/utils'; import LinkToWebmap from '../common/LinkToWebmap'; import { CapturesContext } from '../../context/CapturesContext'; @@ -83,7 +86,11 @@ const columns = [ }, ]; -const CaptureTable = () => { +const CaptureTable = ({ + setShowGallery, + handleShowCaptureDetail, + handleShowGrowerDetail, +}) => { const { filter, rowsPerPage, @@ -99,7 +106,7 @@ const CaptureTable = () => { setOrder, setOrderBy, setCapture, - getCaptureAsync, + getCaptureById, } = useContext(CapturesContext); const speciesContext = useContext(SpeciesContext); const tagsContext = useContext(TagsContext); @@ -160,7 +167,7 @@ const CaptureTable = () => { }; const toggleDrawer = (id) => { - getCaptureAsync(id); + getCaptureById(id); setIsDetailsPaneOpen(!isDetailsPaneOpen); }; @@ -199,7 +206,7 @@ const CaptureTable = () => { const tablePagination = () => { return ( { }; return ( - + - - Captures + + {/* check captureCount is a number and not undefined */} + {!isNaN(captureCount) && + `${countToLocaleString(captureCount)} capture${ + captureCount === 1 ? '' : 's' + }`} + +