diff --git a/src/api/qualityMetrics.js b/src/api/qualityMetrics.js new file mode 100644 index 000000000..7c279a4d7 --- /dev/null +++ b/src/api/qualityMetrics.js @@ -0,0 +1,47 @@ +// @flow + +import inatjs from "inaturalistjs"; + +import handleError from "./error"; + +const setQualityMetric = async ( + params: Object = {}, + opts: Object = {} +): Promise => { + try { + const response = await inatjs.observations.setQualityMetric( params, opts ); + return response.results; + } catch ( e ) { + return handleError( e ); + } +}; + +const deleteQualityMetric = async ( + params: Object = {}, + opts: Object = {} +): Promise => { + try { + const { results } = await inatjs.observations.deleteQualityMetric( params, opts ); + return results; + } catch ( e ) { + return handleError( e ); + } +}; + +const fetchQualityMetrics = async ( + params: Object = {}, + opts: Object = {} +): Promise => { + try { + const response = await inatjs.observations.qualityMetrics( params, opts ); + return response.results; + } catch ( e ) { + return handleError( e ); + } +}; + +export { + deleteQualityMetric, + fetchQualityMetrics, + setQualityMetric +}; diff --git a/src/components/ObsDetails/DQAVoteButtons.js b/src/components/ObsDetails/DQAVoteButtons.js new file mode 100644 index 000000000..fcd77daf6 --- /dev/null +++ b/src/components/ObsDetails/DQAVoteButtons.js @@ -0,0 +1,120 @@ +// @flow + +import { + Body3, + INatIconButton +} from "components/SharedComponents"; +import { View } from "components/styledComponents"; +import * as React from "react"; +import { ActivityIndicator, useTheme } from "react-native-paper"; + +type Props = { + metric: string, + qualityMetrics: Object, + loadingAgree: boolean, + loadingDisagree: boolean, + loadingMetric: ?string, + setVote: Function, + removeVote: Function +} + +const getUserVote = ( metric, qualityMetrics ) => { + if ( qualityMetrics ) { + const match = qualityMetrics.find( element => ( + element.metric === metric && element.user_id ) ); + if ( match ) { + return match.agree === true; + } + } + return null; +}; + +const renderVoteCount = ( status, metric, qualityMetrics ) => { + if ( !qualityMetrics ) return null; + + const count = qualityMetrics + ?.filter( qualityMetric => qualityMetric.agree === status && qualityMetric.metric === metric ) + ?.length; + if ( !count || count === 0 ) return null; + + return {count}; +}; + +const DQAVoteButtons = ( { + metric, qualityMetrics, loadingAgree, loadingDisagree, loadingMetric, setVote, removeVote +}: Props ): React.Node => { + const theme = useTheme( ); + const userAgrees = getUserVote( metric, qualityMetrics ); + const activityIndicatorOffset = "mx-[7px]"; + + const renderAgree = () => { + if ( loadingAgree && loadingMetric === metric ) { + return ( ); + } + if ( userAgrees ) { + return ( + removeVote( metric, true )} + /> + ); + } + return ( + setVote( metric, true )} + /> + ); + }; + + const renderDisagree = () => { + if ( loadingDisagree && loadingMetric === metric ) { + return ( ); + } + + if ( userAgrees === null ) { + return ( + setVote( metric, false )} + /> + ); + } + if ( !userAgrees ) { + return ( + removeVote( metric, false )} + /> + ); + } + return ( + setVote( metric, false )} + /> + ); + }; + + return ( + + + {renderAgree()} + {renderVoteCount( true, metric, qualityMetrics )} + + + {renderDisagree()} + {renderVoteCount( false, metric, qualityMetrics )} + + + ); +}; + +export default DQAVoteButtons; diff --git a/src/components/ObsDetails/DataQualityAssessment.js b/src/components/ObsDetails/DataQualityAssessment.js new file mode 100644 index 000000000..e2d9f035e --- /dev/null +++ b/src/components/ObsDetails/DataQualityAssessment.js @@ -0,0 +1,415 @@ +// @flow +import { useRoute } from "@react-navigation/native"; +import { deleteQualityMetric, fetchQualityMetrics, setQualityMetric } from "api/qualityMetrics"; +import DQAVoteButtons from "components/ObsDetails/DQAVoteButtons"; +import PlaceholderText from "components/PlaceholderText"; +import { + Body3, + BottomSheet, + Button, + Divider, + Heading4, + INatIcon, + List1, + List2, + ScrollViewWrapper +} from "components/SharedComponents"; +import QualityGradeStatus from "components/SharedComponents/QualityGradeStatus/QualityGradeStatus"; +import { View } from "components/styledComponents"; +import { t } from "i18next"; +import { + useEffect, useState +} from "react"; +import * as React from "react"; +import { useTheme } from "react-native-paper"; +import useAuthenticatedMutation from "sharedHooks/useAuthenticatedMutation"; + +const titleOption = option => { + switch ( option ) { + case "research": + return t( "Data-quality-assessment-title-research" ); + case "needs_id": + return t( "Data-quality-assessment-title-needs-id" ); + default: + return t( "Data-quality-assessment-title-casual" ); + } +}; + +const titleDescription = option => { + switch ( option ) { + case "research": + return t( "Data-quality-assessment-description-research" ); + case "needs_id": + return t( "Data-quality-assessment-description-needs-id" ); + default: + return t( "Data-quality-assessment-description-casual" ); + } +}; + +const DataQualityAssessment = ( ): React.Node => { + const { params } = useRoute( ); + const { observationUUID, observation, qualityGrade } = params; + const isResearchGrade = qualityGrade === "research"; + const theme = useTheme( ); + const sectionClass = "flex-row my-[14px] space-x-[11px]"; + const voteClass = "flex-row mr-[15px] my-[7px] justify-between items-center"; + const listTextClass = "flex-row space-x-[11px]"; + const [qualityMetrics, setQualityMetrics] = useState( null ); + const [loadingAgree, setLoadingAgree] = useState( false ); + const [loadingDisagree, setLoadingDisagree] = useState( false ); + const [loadingMetric, setLoadingMetric] = useState( null ); + const [hideErrorSheet, setHideErrorSheet] = useState( true ); + + const fetchMetricsParams = { + id: observationUUID, + fields: "metric,agree,user_id", + ttl: -1 + }; + + // destructured mutate to pass into useEffect to prevent infinite + // rerender and disabling eslint useEffect dependency rule + const { mutate } = useAuthenticatedMutation( + ( qualityMetricParams, optsWithAuth ) => fetchQualityMetrics( + qualityMetricParams, + optsWithAuth + ), + { + onSuccess: response => { + setLoadingMetric( null ); + if ( loadingAgree ) { + setLoadingAgree( false ); + } + if ( loadingDisagree ) { + setLoadingDisagree( false ); + } + setQualityMetrics( response ); + }, + onError: () => { + setHideErrorSheet( false ); + } + } + ); + + useEffect( ( ) => { + mutate( { + id: params.observationUUID, + fields: "metric,agree,user_id", + ttl: -1 + } ); + }, [mutate, params] ); + + const createQualityMetricMutation = useAuthenticatedMutation( + ( qualityMetricParams, optsWithAuth ) => setQualityMetric( qualityMetricParams, optsWithAuth ), + { + onSuccess: () => { + // fetch updated quality metrics with updated votes + mutate( fetchMetricsParams ); + }, + onError: () => { + setHideErrorSheet( false ); + } + } + ); + + const setMetricVote = ( metric, vote ) => { + const qualityMetricParams = { + id: observationUUID, + metric, + agree: vote, + ttyl: -1 + }; + setLoadingMetric( metric ); + if ( vote ) { + setLoadingAgree( true ); + } else { + setLoadingDisagree( true ); + } + createQualityMetricMutation.mutate( qualityMetricParams ); + }; + + const createRemoveQualityMetricMutation = useAuthenticatedMutation( + ( qualityMetricParams, optsWithAuth ) => deleteQualityMetric( + qualityMetricParams, + optsWithAuth + ), + { + onSuccess: () => { + // fetch updated quality metrics with updated votes + mutate( fetchMetricsParams ); + }, + onError: () => { + setHideErrorSheet( false ); + } + } + ); + + const removeMetricVote = ( metric, vote ) => { + const qualityMetricParams = { + id: observationUUID, + metric, + ttyl: -1 + }; + setLoadingMetric( metric ); + if ( vote ) { + setLoadingAgree( true ); + } else { + setLoadingDisagree( true ); + } + createRemoveQualityMetricMutation.mutate( qualityMetricParams ); + }; + + const ifMajorityAgree = metric => { + if ( qualityMetrics ) { + const agreeCount = qualityMetrics.filter( + element => ( element.agree && element.metric === metric ) + ).length; + const disagreeCount = qualityMetrics.filter( + element => ( !element.agree && element.metric === metric ) + ).length; + + return agreeCount >= disagreeCount; + } + return null; + }; + + const renderMetricIndicator = metric => { + const ifAgree = ifMajorityAgree( metric ); + if ( ifAgree || ifAgree === null ) { + return ( + ); + } + return ( + + ); + }; + + const checkTest = metric => { + if ( observation ) { + if ( metric === "date" ) { + return observation[metric] !== null; + } + if ( metric === "location" ) { + const removedNull = observation[metric] + .filter( value => ( value !== null ) ); + return removedNull.length !== 0; + } + if ( metric === "evidence" ) { + const removedEmpty = observation[metric] + .filter( value => ( Object.keys( value ).length !== 0 ) ); + return removedEmpty.length !== 0; + } + if ( observation.taxon ) { + if ( metric === "id_supported" ) { + const taxonId = observation.taxon.id; + const supportedIDs = observation.identifications.filter( + identification => ( identification.taxon.id === taxonId ) + ).length; + return supportedIDs >= 2; + } + if ( metric === "rank" && observation.taxon.rank_level <= 10 ) { + return true; + } + } + } + return false; + }; + + const renderIndicator = metric => { + const ifAgree = checkTest( metric ); + if ( ifAgree || ifAgree === null ) { + return ( + ); + } + return ( + + ); + }; + + return ( + <> + + + + + {isResearchGrade + && ( + + )} + + {titleOption( qualityGrade )} + + + + {titleDescription( qualityGrade )} + + + + + + {renderIndicator( "date" ) } + {t( "Data-quality-assessment-date-specified" )} + + + + + {renderIndicator( "location" )} + {t( "Data-quality-assessment-location-specified" )} + + + + + {renderIndicator( "evidence" )} + {t( "Data-quality-assessment-has-photos-or-sounds" )} + + + + + {renderIndicator( "id_supported" )} + {t( "Data-quality-assessment-id-supported-by-two-or-more" )} + + + + + {renderIndicator( "rank" )} + {t( "Data-quality-assessment-community-taxon-species-level-or-lower" )} + + + + + + {renderMetricIndicator( "date" )} + {t( "Data-quality-assessment-date-is-accurate" )} + + + + + + + + {renderMetricIndicator( "location" )} + {t( "Data-quality-assessment-location-is-accurate" )} + + + + + + + + {renderMetricIndicator( "wild" )} + {t( "Data-quality-assessment-organism-is-wild" )} + + + + + + + + {renderMetricIndicator( "evidence" )} + {t( "Data-quality-assessment-evidence-of-organism" )} + + + + + + + + {renderMetricIndicator( "recent" )} + {t( "Data-quality-assessment-recent-evidence-of-organism" )} + + + + + + + + + + {t( + "Data-quality-assessment-can-taxon-still-be-confirmed-improved-based-on-the-evidence" + )} + + + + + + {t( "ABOUT-THE-DQA" )} + {t( "About-the-DQA-description" )} + + + + + + {t( "Error-voting-in-DQA-description" )} +