MOB-722 merge main

This commit is contained in:
sepeterson
2025-12-09 10:07:34 -06:00
23 changed files with 700 additions and 171 deletions

View File

@@ -170,6 +170,12 @@ module.exports = {
"import/consistent-type-specifier-style": "off"
}
},
{
files: ["*.test.js", "*.test.tsx"],
rules: {
"react/jsx-props-no-spreading": "off"
}
},
{
files: ["**/__mocks__/**/*", "**/*mock*", "**/*.mock.*"],
rules: {

8
src/api/types.d.ts vendored
View File

@@ -105,12 +105,17 @@ export interface ApiUser {
export interface ApiComment {
body?: string;
user?: ApiUser;
id: number;
hidden?: boolean;
uuid: string;
created_at: string;
}
export interface ApiIdentification {
body?: string;
taxon?: ApiTaxon;
user?: ApiUser;
hidden?: boolean;
}
export interface ApiNotification {
@@ -137,6 +142,9 @@ export interface ApiObservation extends ApiRecord {
time_observed_at?: string;
user?: ApiUser;
uuid: string;
comments?: ApiComment[];
identifications?: ApiIdentification[];
taxon?: ApiTaxon;
}
export interface ApiSuggestion {

View File

@@ -15,9 +15,10 @@ import { useTranslation } from "sharedHooks";
interface Props {
topSuggestion?: ApiSuggestion;
hideObservationStatus?: boolean
}
const MatchHeader = ( { topSuggestion }: Props ) => {
const MatchHeader = ( { topSuggestion, hideObservationStatus }: Props ) => {
const { t } = useTranslation( );
const taxon = topSuggestion?.taxon;
@@ -87,17 +88,19 @@ const MatchHeader = ( { topSuggestion }: Props ) => {
return (
<View>
<Body2 className="mb-2">{generateCongratulatoryText( )}</Body2>
{!hideObservationStatus && <Body2 className="mb-2">{generateCongratulatoryText( )}</Body2>}
<View className="flex-row justify-between items-center">
{showSuggestedTaxon( )}
<View className="justify-end items-center ml-5">
<Subheading2 className="text-inatGreen mb-2">
{t( "X-percent", { count: confidence } )}
</Subheading2>
<Body4 className="text-inatGreen">
{t( "Confidence--label" )}
</Body4>
</View>
{ !hideObservationStatus && (
<View className="justify-end items-center ml-5">
<Subheading2 className="text-inatGreen mb-2">
{t( "X-percent", { count: confidence } )}
</Subheading2>
<Body4 className="text-inatGreen">
{t( "Confidence--label" )}
</Body4>
</View>
)}
</View>
</View>
);

View File

@@ -17,14 +17,16 @@ type Props = {
representativePhoto: ApiPhoto,
taxon?: ApiTaxon | RealmTaxon,
obsPhotos: RealmObservationPhoto[],
navToTaxonDetails: ( photo: ApiPhoto ) => void
navToTaxonDetails: ( photo: ApiPhoto ) => void,
hideTaxonPhotos?: boolean
}
const PhotosSection = ( {
representativePhoto,
taxon,
obsPhotos,
navToTaxonDetails
navToTaxonDetails,
hideTaxonPhotos
}: Props ) => {
const [displayPortraitLayout, setDisplayPortraitLayout] = useState<boolean | null>( null );
const [mediaViewerVisible, setMediaViewerVisible] = useState( false );
@@ -88,34 +90,36 @@ const PhotosSection = ( {
let observationPhotoClass = "w-full h-full";
let taxonPhotosContainerClass;
let taxonPhotoClass;
// If there is only one taxon photo: obs photo a square,
// taxon photo a square in the lower right corner of the obs photo
if ( bestTaxonPhotos.length === 1 ) {
containerClass = "flex-row relative";
observationPhotoClass = "w-full h-full";
taxonPhotosContainerClass = "absolute bottom-0 right-0 w-1/3 h-1/3";
taxonPhotoClass = "w-full h-full border-l-[3px] border-t-[3px] border-white";
}
if ( bestTaxonPhotos.length > 1 ) {
if ( displayPortraitLayout ) {
containerClass = "flex-row";
observationPhotoClass = "w-2/3 h-full pr-[3px]";
if ( bestTaxonPhotos.length === 2 ) {
taxonPhotosContainerClass = "flex-col w-1/3 h-full space-y-[3px]";
taxonPhotoClass = "w-full h-1/2";
if ( !hideTaxonPhotos ) {
// If there is only one taxon photo: obs photo a square,
// taxon photo a square in the lower right corner of the obs photo
if ( bestTaxonPhotos.length === 1 ) {
containerClass = "flex-row relative";
observationPhotoClass = "w-full h-full";
taxonPhotosContainerClass = "absolute bottom-0 right-0 w-1/3 h-1/3";
taxonPhotoClass = "w-full h-full border-l-[3px] border-t-[3px] border-white";
}
if ( bestTaxonPhotos.length > 1 ) {
if ( displayPortraitLayout ) {
containerClass = "flex-row";
observationPhotoClass = "w-2/3 h-full pr-[3px]";
if ( bestTaxonPhotos.length === 2 ) {
taxonPhotosContainerClass = "flex-col w-1/3 h-full space-y-[3px]";
taxonPhotoClass = "w-full h-1/2";
} else {
taxonPhotosContainerClass = "flex-col w-1/3 h-full space-y-[3px]";
taxonPhotoClass = "w-full h-1/3";
}
} else {
taxonPhotosContainerClass = "flex-col w-1/3 h-full space-y-[3px]";
taxonPhotoClass = "w-full h-1/3";
}
} else {
containerClass = "flex-col";
observationPhotoClass = "w-full h-2/3 pb-[3px]";
if ( bestTaxonPhotos.length === 2 ) {
taxonPhotosContainerClass = "flex-row w-full h-1/3 space-x-[3px]";
taxonPhotoClass = "w-1/2 h-full";
} else {
taxonPhotosContainerClass = "flex-row w-full h-1/3 space-x-[3px]";
taxonPhotoClass = "w-1/3 h-full";
containerClass = "flex-col";
observationPhotoClass = "w-full h-2/3 pb-[3px]";
if ( bestTaxonPhotos.length === 2 ) {
taxonPhotosContainerClass = "flex-row w-full h-1/3 space-x-[3px]";
taxonPhotoClass = "w-1/2 h-full";
} else {
taxonPhotosContainerClass = "flex-row w-full h-1/3 space-x-[3px]";
taxonPhotoClass = "w-1/3 h-full";
}
}
}
}
@@ -191,9 +195,9 @@ const PhotosSection = ( {
}
return (
<View className={classnames( "h-[390px]", layoutClasses.containerClass )}>
<View className={classnames( "h-[390px] overflow-hidden", layoutClasses.containerClass )}>
{renderObservationPhoto( )}
{bestTaxonPhotos.length > 0 && renderTaxonPhotos( )}
{!hideTaxonPhotos && bestTaxonPhotos.length > 0 && renderTaxonPhotos( )}
<MediaViewerModal
showModal={mediaViewerVisible}
onClose={( ) => setMediaViewerVisible( false )}

View File

@@ -14,7 +14,7 @@ type Props = {
activityItems: Array<Object>,
openAgreeWithIdSheet: Function,
isConnected: boolean,
targetItemID: number,
targetItemID: ?number,
// TODO change to LayoutEvent from react-native if/when switching to TS
onLayoutTargetItem: ( event: Object ) => void
}

View File

@@ -63,7 +63,7 @@ const MapSection = ( { observation, taxon }: Props ) => {
}
return (
<View className="h-[200px]">
<View testID="MapSection" className="h-[200px]">
<Map
mapHeight={200}
observation={observation}

View File

@@ -32,7 +32,6 @@ type Props = {
belongsToCurrentUser: boolean,
currentUser: RealmUser,
isConnected: boolean,
isSimpleMode: boolean,
navToSuggestions: () => void,
observation: RealmObservation & Observation & { id: number },
openAddCommentSheet: () => void,
@@ -52,7 +51,6 @@ const ObsDetailsDefaultMode = ( {
belongsToCurrentUser,
currentUser,
isConnected,
isSimpleMode,
navToSuggestions,
observation,
openAddCommentSheet,
@@ -79,7 +77,7 @@ const ObsDetailsDefaultMode = ( {
const showFloatingButtons = currentUser && !isSavedObservationByCurrentUser;
return (
<View className="flex-1 bg-white">
<View className="flex-1 bg-white" testID="ObsDetails.container">
<ObsDetailsDefaultModeHeaderRight
belongsToCurrentUser={belongsToCurrentUser}
observationId={observation?.id}
@@ -98,13 +96,11 @@ const ObsDetailsDefaultMode = ( {
setHeightOfContentAboveCommunitySection( layout );
}}
>
{!isSimpleMode && (
<ObserverDetails
belongsToCurrentUser={belongsToCurrentUser}
isConnected={isConnected}
observation={observation}
/>
)}
<ObserverDetails
belongsToCurrentUser={belongsToCurrentUser}
isConnected={isConnected}
observation={observation}
/>
<View>
<ObsMediaDisplayContainer observation={observation} />
</View>
@@ -113,10 +109,7 @@ const ObsDetailsDefaultMode = ( {
observation={observation}
isSimpleMode
/>
<View className={isSimpleMode
? "mt-[15px]"
: "mt-5"}
>
<View className="mt-5">
<MapSection observation={observation} />
</View>
<LocationSection
@@ -124,29 +117,25 @@ const ObsDetailsDefaultMode = ( {
observation={observation}
/>
<NotesSection description={observation.description} />
{!isSimpleMode && <View className={cardClassBottom} />}
<View className={cardClassBottom} />
</View>
{!isSimpleMode && (
<>
<CommunitySection
activityItems={activityItems}
isConnected={isConnected}
targetItemID={targetActivityItemID}
observation={observation}
openAgreeWithIdSheet={openAgreeWithIdSheet}
refetchRemoteObservation={refetchRemoteObservation}
onLayoutTargetItem={setOffsetToActivityItem}
/>
{addingActivityItem && (
<View className="flex-row items-center justify-center p-10">
<ActivityIndicator size={50} />
</View>
)}
<StatusSection observation={observation} />
<DetailsSection observation={observation} />
<MoreSection observation={observation} />
</>
<CommunitySection
activityItems={activityItems}
isConnected={isConnected}
targetItemID={targetActivityItemID}
observation={observation}
openAgreeWithIdSheet={openAgreeWithIdSheet}
refetchRemoteObservation={refetchRemoteObservation}
onLayoutTargetItem={setOffsetToActivityItem}
/>
{addingActivityItem && (
<View className="flex-row items-center justify-center p-10">
<ActivityIndicator size={50} />
</View>
)}
<StatusSection observation={observation} />
<DetailsSection observation={observation} />
<MoreSection observation={observation} />
</ScrollView>
{showFloatingButtons && (
<FloatingButtons

View File

@@ -1,8 +1,5 @@
// @flow
import {
useNetInfo
} from "@react-native-community/netinfo";
import { useFocusEffect, useNavigation, useRoute } from "@react-navigation/native";
import { useFocusEffect, useNavigation } from "@react-navigation/native";
import { useQueryClient } from "@tanstack/react-query";
import { fetchSubscriptions } from "api/observations";
import IdentificationSheets from "components/ObsDetailsDefaultMode/IdentificationSheets";
@@ -11,20 +8,16 @@ import type { Node } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useReducer,
useState
useReducer
} from "react";
import { LogBox } from "react-native";
import Observation from "realmModels/Observation";
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
import {
useAuthenticatedQuery,
useCurrentUser,
useLocalObservation,
useObservationsUpdates
} from "sharedHooks";
import useRemoteObservation, {
import {
fetchRemoteObservationKey
} from "sharedHooks/useRemoteObservation";
import useStore from "stores/useStore";
@@ -109,19 +102,47 @@ const reducer = ( state, action ) => {
}
};
const ObsDetailsDefaultModeContainer = ( ): Node => {
type Props = {
belongsToCurrentUser: boolean,
currentUser: ?Object,
fetchRemoteObservationError: ?Object,
isConnected: boolean,
isRefetching: boolean,
localObservation: ?Object,
markDeletedLocally: Function,
markViewedLocally: Function,
observation: Object,
refetchRemoteObservation: Function,
remoteObservation: ?Object,
remoteObsWasDeleted: boolean,
setRemoteObsWasDeleted: Function,
targetActivityItemID?: ?number,
uuid: string
}
const ObsDetailsDefaultModeContainer = ( props: Props ): Node => {
const setObservations = useStore( state => state.setObservations );
const currentUser = useCurrentUser( );
const { params } = useRoute();
const {
targetActivityItemID,
uuid
} = params;
const navigation = useNavigation( );
const realm = useRealm( );
const { isConnected } = useNetInfo( );
const [state, dispatch] = useReducer( reducer, initialState );
const [remoteObsWasDeleted, setRemoteObsWasDeleted] = useState( false );
const {
observation,
targetActivityItemID,
uuid,
localObservation,
markViewedLocally,
markDeletedLocally,
remoteObservation,
setRemoteObsWasDeleted,
fetchRemoteObservationError,
currentUser,
belongsToCurrentUser,
isRefetching,
refetchRemoteObservation,
isConnected,
remoteObsWasDeleted
} = props;
const {
activityItems,
@@ -133,26 +154,6 @@ const ObsDetailsDefaultModeContainer = ( ): Node => {
} = state;
const queryClient = useQueryClient( );
const {
localObservation,
markDeletedLocally,
markViewedLocally
} = useLocalObservation( uuid );
const wasSynced = localObservation && localObservation?.wasSynced();
const fetchRemoteObservationEnabled = !!(
!remoteObsWasDeleted
&& ( !localObservation || localObservation?.wasSynced( ) )
&& isConnected
);
const {
remoteObservation,
refetchRemoteObservation,
isRefetching,
fetchRemoteObservationError
} = useRemoteObservation( uuid, fetchRemoteObservationEnabled );
useMarkViewedMutation( localObservation, markViewedLocally, remoteObservation );
// If we tried to get a remote observation but it no longer exists, the user
@@ -160,7 +161,8 @@ const ObsDetailsDefaultModeContainer = ( ): Node => {
// copy of this observation
useEffect( ( ) => {
setRemoteObsWasDeleted( fetchRemoteObservationError?.status === 404 );
}, [fetchRemoteObservationError?.status] );
}, [fetchRemoteObservationError?.status, setRemoteObsWasDeleted] );
const confirmRemoteObsWasDeleted = useCallback( ( ) => {
if ( localObservation ) {
markDeletedLocally( );
@@ -172,27 +174,10 @@ const ObsDetailsDefaultModeContainer = ( ): Node => {
navigation
] );
const observation = localObservation || Observation.mapApiToRealm( remoteObservation );
const wasSynced = !!( localObservation && localObservation?.wasSynced() );
const hasPhotos = observation?.observationPhotos?.length > 0;
// In theory the only situation in which an observation would not have a
// user is when a user is not signed but has made a new observation in the
// app. Also in theory that user should not be able to get to ObsDetail for
// those observations, just ObsEdit. But.... let's be safe.
const belongsToCurrentUser = (
observation?.user?.id === currentUser?.id
|| ( !observation?.user && !observation?.id )
);
const isSimpleMode = useMemo( () => (
// Simple mode applies only when:
// 1. It's the current user's observation (or an observation being created)
// 2. AND the observation hasn't been synced yet
( belongsToCurrentUser || !observation?.user )
&& localObservation
&& !localObservation.wasSynced()
), [belongsToCurrentUser, localObservation, observation?.user] );
const { data: subscriptions, refetch: refetchSubscriptions } = useAuthenticatedQuery(
[
"fetchSubscriptions"
@@ -325,7 +310,7 @@ const ObsDetailsDefaultModeContainer = ( ): Node => {
const localComments = localObservation?.comments;
const newComment = data[0];
newComment.user = currentUser;
localComments.push( newComment );
localComments?.push( newComment );
}, "setting local comment in ObsDetailsContainer" );
const updatedLocalObservation = realm.objectForPrimaryKey( "Observation", uuid );
dispatch( { type: "ADD_ACTIVITY_ITEM", observationShown: updatedLocalObservation } );
@@ -355,7 +340,6 @@ const ObsDetailsDefaultModeContainer = ( ): Node => {
belongsToCurrentUser={belongsToCurrentUser}
currentUser={currentUser}
isConnected={isConnected}
isSimpleMode={isSimpleMode}
navToSuggestions={navToSuggestions}
observation={observationShown}
openAddCommentSheet={openAddCommentSheet}

View File

@@ -0,0 +1,99 @@
import { useNetInfo } from "@react-native-community/netinfo";
import { useRoute } from "@react-navigation/native";
import ObsDetailsDefaultModeContainer
from "components/ObsDetailsDefaultMode/ObsDetailsDefaultModeContainer";
import React, { useMemo, useState } from "react";
import Observation from "realmModels/Observation";
import {
useCurrentUser,
useLocalObservation,
useRemoteObservation
} from "sharedHooks";
import SavedMatchContainer from "./SavedMatch/SavedMatchContainer";
type RouteParams = {
targetActivityItemID?: number,
uuid: string,
}
const ObsDetailsDefaultModeScreensWrapper = () => {
const { params } = useRoute();
const {
targetActivityItemID,
uuid
} = params as RouteParams;
const currentUser = useCurrentUser( );
const isConnected = !!useNetInfo( ).isConnected;
const [remoteObsWasDeleted, setRemoteObsWasDeleted] = useState( false );
const {
localObservation,
markDeletedLocally,
markViewedLocally
} = useLocalObservation( uuid );
const fetchRemoteObservationEnabled = !!(
!remoteObsWasDeleted
&& ( !localObservation || localObservation?.wasSynced( ) )
&& isConnected
);
const {
remoteObservation,
refetchRemoteObservation,
isRefetching,
fetchRemoteObservationError
} = useRemoteObservation( uuid, fetchRemoteObservationEnabled );
const observation = localObservation || Observation.mapApiToRealm( remoteObservation );
// In theory the only situation in which an observation would not have a
// user is when a user is not signed but has made a new observation in the
// app. Also in theory that user should not be able to get to ObsDetail for
// those observations, just ObsEdit. But.... let's be safe.
const belongsToCurrentUser = (
observation?.user?.id === currentUser?.id
|| ( !observation?.user && !observation?.id )
);
const showSavedMatch = useMemo( () => (
// Saved match screen is used when:
// 1. It's the current user's observation (or an observation being created)
// 2. AND the observation hasn't been synced yet
!!( ( belongsToCurrentUser || !observation?.user )
&& localObservation
&& !localObservation.wasSynced() )
), [belongsToCurrentUser, localObservation, observation?.user] );
if ( showSavedMatch ) {
return (
<SavedMatchContainer
observation={observation}
/>
);
}
return (
<ObsDetailsDefaultModeContainer
observation={observation}
targetActivityItemID={targetActivityItemID}
uuid={uuid}
localObservation={localObservation}
markViewedLocally={markViewedLocally}
markDeletedLocally={markDeletedLocally}
remoteObservation={remoteObservation}
remoteObsWasDeleted={remoteObsWasDeleted}
setRemoteObsWasDeleted={setRemoteObsWasDeleted}
fetchRemoteObservationError={fetchRemoteObservationError}
currentUser={currentUser}
belongsToCurrentUser={belongsToCurrentUser}
isRefetching={isRefetching}
refetchRemoteObservation={refetchRemoteObservation}
isConnected={isConnected}
/>
);
};
export default ObsDetailsDefaultModeScreensWrapper;

View File

@@ -0,0 +1,69 @@
import { useNetInfo } from "@react-native-community/netinfo";
import { matchCardClassBottom, matchCardClassTop } from "components/Match/Match";
import MatchHeader from "components/Match/MatchHeader";
import PhotosSection from "components/Match/PhotosSection";
import LocationSection from "components/ObsDetailsDefaultMode/LocationSection/LocationSection";
import MapSection from "components/ObsDetailsDefaultMode/MapSection/MapSection";
import { Button, ScrollViewWrapper } from "components/SharedComponents";
import { View } from "components/styledComponents";
import _ from "lodash";
import React from "react";
import type { RealmObservation } from "realmModels/types";
import { useTranslation } from "sharedHooks";
import SavedMatchHeaderRight from "./SavedMatchHeaderRight";
interface Props {
observation: RealmObservation,
navToTaxonDetails: ( ) => void,
}
const SavedMatch = ( {
observation,
navToTaxonDetails
}: Props ) => {
const { t } = useTranslation( );
const { isConnected } = useNetInfo( );
const latitude = observation?.privateLatitude || observation?.latitude;
const { taxon } = observation;
return (
<ScrollViewWrapper testID="SavedMatch.container">
<SavedMatchHeaderRight observation={observation} />
<View className={`${matchCardClassTop} mt-[10px]`}>
<MatchHeader hideObservationStatus topSuggestion={observation} />
</View>
<PhotosSection
taxon={taxon}
obsPhotos={observation.observationPhotos}
navToTaxonDetails={navToTaxonDetails}
hideTaxonPhotos={!isConnected}
/>
<View className="border-[1.5px] border-white" />
{latitude && (
<MapSection observation={observation} taxon={taxon} />
)}
<LocationSection
belongsToCurrentUser
observation={observation}
/>
<View className={matchCardClassBottom} />
{
isConnected && (
<Button
className="mx-4 mb-[30px]"
level="primary"
text={taxon?.rank_level === 10
? t( "LEARN-MORE-ABOUT-THIS-SPECIES" )
: t( "LEARN-MORE-ABOUT-THIS-GROUP" )}
onPress={navToTaxonDetails}
accessibilityHint={t( "Navigates-to-taxon-details" )}
/>
)
}
</ScrollViewWrapper>
);
};
export default SavedMatch;

View File

@@ -0,0 +1,28 @@
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import SavedMatch from "components/ObsDetailsDefaultMode/SavedMatch/SavedMatch";
import _ from "lodash";
import React from "react";
import type { RealmObservation } from "realmModels/types";
interface Props {
observation: RealmObservation,
}
const SavedMatchContainer = ( { observation }: Props ) => {
const navigation = useNavigation<NativeStackNavigationProp<Record<string, { id?: number }>>>( );
const navToTaxonDetails = () => {
const navParams = { id: observation.taxon?.id };
navigation.push( "TaxonDetails", navParams );
};
return (
<SavedMatch
observation={observation}
navToTaxonDetails={navToTaxonDetails}
/>
);
};
export default SavedMatchContainer;

View File

@@ -0,0 +1,50 @@
import { useNavigation } from "@react-navigation/native";
import {
INatIconButton
} from "components/SharedComponents";
import React, { useCallback, useEffect } from "react";
import type { RealmObservation } from "realmModels/types";
import {
useNavigateToObsEdit,
useTranslation
} from "sharedHooks";
import colors from "styles/tailwindColors";
interface Props {
observation: RealmObservation;
}
const SavedMatchHeaderRight = ( {
observation
}: Props ) => {
const navigation = useNavigation( );
const { t } = useTranslation( );
const navigateToObsEdit = useNavigateToObsEdit( );
const headerRight = useCallback(
( ) => (
<INatIconButton
testID="SavedMatch.editButton"
onPress={() => navigateToObsEdit( observation )}
icon="pencil"
color={String( colors?.darkGray )}
accessibilityLabel={t( "Edit" )}
size={22}
/>
),
[
observation,
navigateToObsEdit,
t
]
);
useEffect(
( ) => navigation.setOptions( { headerRight } ),
[headerRight, navigation]
);
return null;
};
export default SavedMatchHeaderRight;

View File

@@ -333,7 +333,7 @@ Highest = Höchste
HIGHEST-RANK = HÖCHSTER RANG
I-agree-to-the-Terms-of-Use = <0>Ich stimme den Nutzungsbedingungen und der Datenschutzrichtlinie zu und habe die Richtlinien der Gemeinschaft gelesen (</0><1>erforderlich</1><0>).</0>
Iconic-taxon-name = Ikonischer Taxonname: { $iconicTaxon }
ID-in-Camera = ID in Camera
ID-in-Camera = ID in der Kamera
ID-Suggestions = ID Vorschläge
ID-Withdrawn = ID zurückgezogen
IDENTIFICATION = BESTIMMUNG
@@ -345,7 +345,7 @@ IDENTIFICATIONS-WITHOUT-NUMBER =
}
Identifiers = Bestimmer
Identifiers-View = Bestimmer-Ansicht
IDENTIFY = IDENTIFY
IDENTIFY = BESTIMMUNG
Identify-organisms-in-real-time-with-your-camera = Bestimmung von Lebewesen in Echtzeit mit der Kamera
Identify-species-anywhere = Arten bestimmen, egal wo auf der Welt
If-an-account-with-that-email-exists = Wenn ein Konto mit dieser E-Mail-Adresse existiert, wurden Anweisungen zum Zurücksetzen des Passworts an deine E-Mail-Adresse gesendet.

View File

@@ -311,7 +311,7 @@
"HIGHEST-RANK": "HÖCHSTER RANG",
"I-agree-to-the-Terms-of-Use": "<0>Ich stimme den Nutzungsbedingungen und der Datenschutzrichtlinie zu und habe die Richtlinien der Gemeinschaft gelesen (</0><1>erforderlich</1><0>).</0>",
"Iconic-taxon-name": "Ikonischer Taxonname: { $iconicTaxon }",
"ID-in-Camera": "ID in Camera",
"ID-in-Camera": "ID in der Kamera",
"ID-Suggestions": "ID Vorschläge",
"ID-Withdrawn": "ID zurückgezogen",
"IDENTIFICATION": "BESTIMMUNG",
@@ -319,7 +319,7 @@
"IDENTIFICATIONS-WITHOUT-NUMBER": "{ $count ->\n [one] BESTIMMUNG\n *[other] BESTIMMUNGEN\n}",
"Identifiers": "Bestimmer",
"Identifiers-View": "Bestimmer-Ansicht",
"IDENTIFY": "IDENTIFY",
"IDENTIFY": "BESTIMMUNG",
"Identify-organisms-in-real-time-with-your-camera": "Bestimmung von Lebewesen in Echtzeit mit der Kamera",
"Identify-species-anywhere": "Arten bestimmen, egal wo auf der Welt",
"If-an-account-with-that-email-exists": "Wenn ein Konto mit dieser E-Mail-Adresse existiert, wurden Anweisungen zum Zurücksetzen des Passworts an deine E-Mail-Adresse gesendet.",

View File

@@ -148,7 +148,7 @@ Couldnt-create-comment = לא נוצרה תגובה
Couldnt-create-identification-error = לא היתה אפשרות ליצור זיהוי { $error }
Couldnt-create-identification-unknown-error = לא היתה אפשרות ליצור זיהוי, שגיאה לא ידועה.
CREATE-AN-ACCOUNT = פתיחת חשבון
Create-observation-with-no-evidence = Create observation with no evidence
Create-observation-with-no-evidence = יצירת תצפית ללא ראיות
DATA-QUALITY = איכות הנתונים
DATA-QUALITY-ASSESSMENT = הערכת איכות נתונים
Data-Quality-Assessment = הערכת איכות הנתונים
@@ -333,7 +333,7 @@ Highest = הכי גבוה
HIGHEST-RANK = הדרגה הגבוהה ביותר
I-agree-to-the-Terms-of-Use = <0>אני מסכימ.ה לתנאי השימוש ולמדיניות הפרטיות, ועיינתי בהנחיות הקהילה (חובה</0><1></1><0>).</0>
Iconic-taxon-name = שם טקסון איקוני: { $iconicTaxon }
ID-in-Camera = ID in Camera
ID-in-Camera = זיהוי במצלמה
ID-Suggestions = הצעות לזיהוי
ID-Withdrawn = זיהוי נמשך
IDENTIFICATION = זיהוי
@@ -345,7 +345,7 @@ IDENTIFICATIONS-WITHOUT-NUMBER =
}
Identifiers = מזהות.ים
Identifiers-View = תצוגת מזהים
IDENTIFY = IDENTIFY
IDENTIFY = לזהות
Identify-organisms-in-real-time-with-your-camera = זהו אורגניזמים בזמן אמת באמצעות המצלמה שלך
Identify-species-anywhere = זהו מינים בכל מקום
If-an-account-with-that-email-exists = אם קיים חשבון עם דוא"ל זה, שלחנו הוראות לאיפוס הסיסמה לדוא"ל שלך.
@@ -638,7 +638,7 @@ Ranks-INFRAORDER = אינפרא-סדרה
Ranks-Infraorder = אנפרא-סדרה
Ranks-KINGDOM = ממלכה
Ranks-Kingdom = ממלכה
Ranks-ORDER = הזמנה
Ranks-ORDER = סדרה
Ranks-Order = סדרה
Ranks-PARVORDER = תת-תת-סדרה
Ranks-Parvorder = תת-תת-סדרה
@@ -815,7 +815,7 @@ Switches-to-tab = עובר ללשונית { $tab }.
Sync-observations = סנכרון תצפיות
Syncing = מסנכרן...
Take-photo = צלמ.י תמונה
Take-photos = Take photos
Take-photos = צלמ.י תמונות
Taxa = טקסונים
TAXON = טקסון
TAXON-NAMES-DISPLAY = תצוגת שמות טקסונים
@@ -870,7 +870,7 @@ Unreviewed-observations-only = תצפיות שלא נבדקו בלבד
Upload-Complete = ההעלאה הושלמה
Upload-in-progress = ההעלאה מתבצעת
UPLOAD-NOW = העלאה עכשיו
Upload-photos = Upload photos
Upload-photos = העלאת תמונות
Upload-Progress = ההעלאה הושלמה ב-{ $uploadProgress } אחוזים
UPLOAD-TO-INATURALIST = העלאה ל-INATURALIST
Upload-x-observations =

View File

@@ -142,7 +142,7 @@
"Couldnt-create-identification-error": "לא היתה אפשרות ליצור זיהוי { $error }",
"Couldnt-create-identification-unknown-error": "לא היתה אפשרות ליצור זיהוי, שגיאה לא ידועה.",
"CREATE-AN-ACCOUNT": "פתיחת חשבון",
"Create-observation-with-no-evidence": "Create observation with no evidence",
"Create-observation-with-no-evidence": "יצירת תצפית ללא ראיות",
"DATA-QUALITY": "איכות הנתונים",
"DATA-QUALITY-ASSESSMENT": "הערכת איכות נתונים",
"Data-Quality-Assessment": "הערכת איכות הנתונים",
@@ -311,7 +311,7 @@
"HIGHEST-RANK": "הדרגה הגבוהה ביותר",
"I-agree-to-the-Terms-of-Use": "<0>אני מסכימ.ה לתנאי השימוש ולמדיניות הפרטיות, ועיינתי בהנחיות הקהילה (חובה</0><1></1><0>).</0>",
"Iconic-taxon-name": "שם טקסון איקוני: { $iconicTaxon }",
"ID-in-Camera": "ID in Camera",
"ID-in-Camera": "זיהוי במצלמה",
"ID-Suggestions": "הצעות לזיהוי",
"ID-Withdrawn": "זיהוי נמשך",
"IDENTIFICATION": "זיהוי",
@@ -319,7 +319,7 @@
"IDENTIFICATIONS-WITHOUT-NUMBER": "{ $count ->\n [one] זיהוי\n *[other] זיהויים\n}",
"Identifiers": "מזהות.ים",
"Identifiers-View": "תצוגת מזהים",
"IDENTIFY": "IDENTIFY",
"IDENTIFY": "לזהות",
"Identify-organisms-in-real-time-with-your-camera": "זהו אורגניזמים בזמן אמת באמצעות המצלמה שלך",
"Identify-species-anywhere": "זהו מינים בכל מקום",
"If-an-account-with-that-email-exists": "אם קיים חשבון עם דוא\"ל זה, שלחנו הוראות לאיפוס הסיסמה לדוא\"ל שלך.",
@@ -588,7 +588,7 @@
"Ranks-Infraorder": "אנפרא-סדרה",
"Ranks-KINGDOM": "ממלכה",
"Ranks-Kingdom": "ממלכה",
"Ranks-ORDER": "הזמנה",
"Ranks-ORDER": "סדרה",
"Ranks-Order": "סדרה",
"Ranks-PARVORDER": "תת-תת-סדרה",
"Ranks-Parvorder": "תת-תת-סדרה",
@@ -761,7 +761,7 @@
"Sync-observations": "סנכרון תצפיות",
"Syncing": "מסנכרן...",
"Take-photo": "צלמ.י תמונה",
"Take-photos": "Take photos",
"Take-photos": "צלמ.י תמונות",
"Taxa": "טקסונים",
"TAXON": "טקסון",
"TAXON-NAMES-DISPLAY": "תצוגת שמות טקסונים",
@@ -816,7 +816,7 @@
"Upload-Complete": "ההעלאה הושלמה",
"Upload-in-progress": "ההעלאה מתבצעת",
"UPLOAD-NOW": "העלאה עכשיו",
"Upload-photos": "Upload photos",
"Upload-photos": "העלאת תמונות",
"Upload-Progress": "ההעלאה הושלמה ב-{ $uploadProgress } אחוזים",
"UPLOAD-TO-INATURALIST": "העלאה ל-INATURALIST",
"Upload-x-observations": "העלאת { $count ->\n [one] תצפית אחת\n *[other] { $count } תצפיות\n}",

View File

@@ -19,8 +19,8 @@ import MyObservationsContainer from "components/MyObservations/MyObservationsCon
import Notifications from "components/Notifications/Notifications";
import DQAContainer from "components/ObsDetails/DQAContainer";
import ObsDetailsContainer from "components/ObsDetails/ObsDetailsContainer";
import ObsDetailsDefaultModeContainer
from "components/ObsDetailsDefaultMode/ObsDetailsDefaultModeContainer";
import ObsDetailsDefaultModeScreensWrapper
from "components/ObsDetailsDefaultMode/ObsDetailsDefaultModeScreensWrapper";
import ProjectDetailsContainer from "components/ProjectDetails/ProjectDetailsContainer";
import ProjectMembers from "components/ProjectDetails/ProjectMembers";
import ProjectRequirements from "components/ProjectDetails/ProjectRequirements";
@@ -129,8 +129,8 @@ const FadeInRootExplore = ( ) => fadeInComponent( <RootExploreContainer /> );
const FadeInMyObservations = ( ) => fadeInComponent( <MyObservationsContainer /> );
const FadeInUserProfile = ( ) => fadeInComponent( <UserProfile /> );
const FadeInExploreContainer = ( ) => fadeInComponent( <ExploreContainer /> );
const FadeInObsDetailsDefaultModeContainer = ( ) => fadeInComponent(
<ObsDetailsDefaultModeContainer />
const FadeInObsDetailsDefaultModeScreensWrapper = ( ) => fadeInComponent(
<ObsDetailsDefaultModeScreensWrapper />
);
const FadeInObsDetailsContainer = ( ) => fadeInComponent(
<ObsDetailsContainer />
@@ -226,7 +226,7 @@ const TabStackNavigator = ( { route }: TabStackNavigatorProps ): Node => {
? (
<Stack.Screen
name="ObsDetails"
component={FadeInObsDetailsDefaultModeContainer}
component={FadeInObsDetailsDefaultModeScreensWrapper}
options={OBS_DETAILS_OPTIONS}
/>
)

View File

@@ -1,5 +1,5 @@
// @flow
import { fetchRemoteObservation } from "api/observations";
import type { ApiObservation } from "api/types";
import i18n from "i18next";
import { RealmContext } from "providers/contexts";
import { useCallback, useEffect, useMemo } from "react";
@@ -10,7 +10,15 @@ const { useRealm } = RealmContext;
export const fetchRemoteObservationKey = "fetchRemoteObservation";
const filterHiddenContent = observation => {
interface UseRemoteObservationReturn {
remoteObservation: ApiObservation | null | undefined;
refetchRemoteObservation: () => void;
isRefetching: boolean;
fetchRemoteObservationError: Error | null;
}
const filterHiddenContent
= ( observation?: ApiObservation | null ): ApiObservation | null | undefined => {
if ( observation === undefined || observation === null ) {
return observation;
}
@@ -24,7 +32,7 @@ const filterHiddenContent = observation => {
return filteredObservation;
};
const useRemoteObservation = ( uuid: string, enabled: boolean ): Object => {
const useRemoteObservation = ( uuid: string, enabled: boolean ): UseRemoteObservationReturn => {
const fetchRemoteObservationQueryKey = useMemo(
( ) => ( [fetchRemoteObservationKey, uuid] ),
[uuid]
@@ -40,7 +48,7 @@ const useRemoteObservation = ( uuid: string, enabled: boolean ): Object => {
refetch: refetchRemoteObservation,
isRefetching,
error: fetchRemoteObservationError
} = useAuthenticatedQuery(
} = useAuthenticatedQuery<ApiObservation | null>(
fetchRemoteObservationQueryKey,
optsWithAuth => fetchRemoteObservation(
uuid,

View File

@@ -1,7 +1,7 @@
import { screen, waitFor } from "@testing-library/react-native";
import ObsDetailsContainer from "components/ObsDetails/ObsDetailsContainer";
import DefaultModeObsDetailsContainer
from "components/ObsDetailsDefaultMode/ObsDetailsDefaultModeContainer";
import ObsDetailsDefaultModeScreensWrapper
from "components/ObsDetailsDefaultMode/ObsDetailsDefaultModeScreensWrapper";
import inatjs from "inaturalistjs";
import React from "react";
import Observation from "realmModels/Observation";
@@ -71,7 +71,7 @@ jest.mock( "@react-navigation/native", () => {
// Run the same suite of tests for multiple ObsDetails container
describe.each( [
{ Container: ObsDetailsContainer, name: "ObsDetailsContainer" },
{ Container: DefaultModeObsDetailsContainer, name: "DefaultModeObsDetailsContainer" }
{ Container: ObsDetailsDefaultModeScreensWrapper, name: "ObsDetailsDefaultModeScreensWrapper" }
] )( "ObsDetails", ( { Container, name } ) => {
beforeAll( async () => {
jest.useFakeTimers( );

View File

@@ -0,0 +1,106 @@
import { useNetInfo } from "@react-native-community/netinfo";
import { screen } from "@testing-library/react-native";
import SavedMatchContainer from "components/ObsDetailsDefaultMode/SavedMatch/SavedMatchContainer";
import { t } from "i18next";
import React from "react";
import factory from "tests/factory";
import faker from "tests/helpers/faker";
import { renderAppWithComponent } from "tests/helpers/render";
import setupUniqueRealm from "tests/helpers/uniqueRealm";
// UNIQUE REALM SETUP
const mockRealmIdentifier = __filename;
const { mockRealmModelsIndex, uniqueRealmBeforeAll, uniqueRealmAfterAll } = setupUniqueRealm(
mockRealmIdentifier
);
jest.mock( "realmModels/index", ( ) => mockRealmModelsIndex );
jest.mock( "providers/contexts", ( ) => {
const originalModule = jest.requireActual( "providers/contexts" );
return {
__esModule: true,
...originalModule,
RealmContext: {
...originalModule.RealmContext,
useRealm: ( ) => global.mockRealms[mockRealmIdentifier],
useQuery: ( ) => []
}
};
} );
beforeAll( uniqueRealmBeforeAll );
afterAll( uniqueRealmAfterAll );
// /UNIQUE REALM SETUP
const mockObservation = factory( "LocalObservation", {
_created_at: faker.date.past( ),
created_at: "2022-11-27T19:07:41-08:00",
time_observed_at: "2023-12-14T21:07:41-09:30",
observationPhotos: [
factory( "LocalObservationPhoto", {
photo: {
id: faker.number.int( ),
attribution: faker.lorem.sentence( ),
licenseCode: "cc-by-nc",
url: faker.image.url( )
}
} )
],
taxon: factory( "LocalTaxon", {
name: faker.person.firstName( ),
rank: "species",
rank_level: 10,
preferred_common_name: faker.person.fullName( ),
defaultPhoto: {
id: faker.number.int( ),
attribution: faker.lorem.sentence( ),
licenseCode: "cc-by-nc",
url: faker.image.url( )
}
} ),
user: factory( "LocalUser", {
login: faker.internet.userName( ),
iconUrl: faker.image.url( ),
locale: "en"
} ),
identifications: []
} );
const mockPush = jest.fn();
jest.mock( "@react-navigation/native", () => {
const actualNav = jest.requireActual( "@react-navigation/native" );
return {
...actualNav,
useNavigation: () => ( {
push: mockPush,
setOptions: jest.fn()
} )
};
} );
describe( "SavedMatch", ( ) => {
beforeEach( async ( ) => {
jest.clearAllMocks( );
} );
it( "should not show learn more button when offline", async ( ) => {
useNetInfo.mockImplementation( ( ) => ( { isConnected: false } ) );
renderAppWithComponent( <SavedMatchContainer observation={mockObservation} /> );
const learnMoreButton = screen.queryByText( t( "LEARN-MORE-ABOUT-THIS-SPECIES" ) );
expect( learnMoreButton ).toBeFalsy( );
} );
it( "should not show map section for no latitude", async ( ) => {
renderAppWithComponent( <SavedMatchContainer observation={mockObservation} /> );
const mapSection = screen.queryByTestId( "MapSection" );
expect( mapSection ).toBeFalsy( );
} );
const mockObservationWithLatitude = factory( "LocalObservation", {
latitude: faker.location.latitude( )
} );
it( "should show map section when latitude is present", async ( ) => {
renderAppWithComponent( <SavedMatchContainer observation={mockObservationWithLatitude} /> );
const mapSection = screen.queryByTestId( "MapSection" );
expect( mapSection ).toBeTruthy( );
} );
} );

View File

@@ -275,6 +275,14 @@ describe( "DisplayTaxonName", ( ) => {
} );
} );
describe( "when taxon is undefined", ( ) => {
it( "it displays fallback text", ( ) => {
let taxon;
render( <DisplayTaxonName taxon={taxon} /> );
expect( screen.getByText( /Unknown/ ) ).toBeTruthy( );
} );
} );
describe( "when displayed as plain text within a Trans component", ( ) => {
it( "it displays common name followed by scientific name", async ( ) => {
render( <DisplayTaxonName taxon={subspeciesTaxon} removeStyling layout="horizontal" /> );

View File

@@ -128,8 +128,32 @@ jest.mock( "sharedHooks/useObservationsUpdates", () => ( {
} ) )
} ) );
const renderObsDetails = ( ) => renderComponent(
<ObsDetailsContainer />
const mockRefetchRemoteObservation = jest.fn();
const mockMarkViewedLocally = jest.fn();
const mockMarkDeletedLocally = jest.fn();
const mockSetRemoteObsWasDeleted = jest.fn();
const defaultProps = {
belongsToCurrentUser: false,
currentUser: mockUser,
fetchRemoteObservationError: null,
isConnected: true,
isRefetching: false,
isSimpleMode: false,
localObservation: null,
markDeletedLocally: mockMarkDeletedLocally,
markViewedLocally: mockMarkViewedLocally,
observation: mockObservation,
refetchRemoteObservation: mockRefetchRemoteObservation,
remoteObservation: mockObservation,
remoteObsWasDeleted: false,
setRemoteObsWasDeleted: mockSetRemoteObsWasDeleted,
targetActivityItemID: null,
uuid: mockObservation.uuid
};
const renderObsDetails = ( props = {} ) => renderComponent(
<ObsDetailsContainer {...defaultProps} {...props} />
);
describe( "ObsDetails", () => {
@@ -156,7 +180,11 @@ describe( "ObsDetails", () => {
} );
it( "should render fallback image icon instead of photos", async () => {
renderObsDetails( );
renderObsDetails( {
observation: mockNoEvidenceObservation,
remoteObservation: mockNoEvidenceObservation,
uuid: mockNoEvidenceObservation.uuid
} );
const labelText = t( "Observation-has-no-photos-and-no-sounds" );
const fallbackImage = await screen.findByLabelText( labelText );
@@ -194,7 +222,13 @@ describe( "ObsDetails", () => {
} );
jest.spyOn( useCurrentUser, "default" ).mockImplementation( () => mockUser );
renderObsDetails( );
renderObsDetails( {
observation: otherUserObservation,
remoteObservation: otherUserObservation,
uuid: otherUserObservation.uuid,
belongsToCurrentUser: false,
localObservation: null
} );
const agreeButton = screen.getByTestId(
`ActivityItem.AgreeIdButton.${firstIdentification.taxon.id}`
);

View File

@@ -0,0 +1,133 @@
import { screen } from "@testing-library/react-native";
import ObsDetailsDefaultModeScreensWrapper
from "components/ObsDetailsDefaultMode/ObsDetailsDefaultModeScreensWrapper";
import React from "react";
import * as useLocalObservation from "sharedHooks/useLocalObservation";
import factory from "tests/factory";
import faker from "tests/helpers/faker";
import { renderComponent } from "tests/helpers/render";
const mockCurrentUser = factory( "LocalUser", {
id: 123,
login: faker.internet.userName( )
} );
const mockLocalObservation = factory( "LocalObservation", {
user: factory( "LocalUser", {
id: 123
} ),
uuid: "test-123"
} );
jest.mock( "@react-native-community/netinfo", () => ( {
useNetInfo: () => ( { isConnected: true } ),
configure: jest.fn( )
} ) );
jest.mock( "sharedHooks/useCurrentUser", () => ( {
__esModule: true,
default: () => mockCurrentUser
} ) );
jest.mock( "sharedHooks/useLocalObservation", () => ( {
__esModule: true,
default: jest.fn( () => ( {
localObservation: null
} ) )
} ) );
jest.mock( "sharedHooks/useRemoteObservation", ( ) => ( {
__esModule: true,
default: ( _uuid, _fetchRemoteEnabled ) => ( {
remoteObservation: null,
refetchRemoteObservation: jest.fn( ),
isRefetching: false,
fetchRemoteObservationError: null
} )
} ) );
describe( "ObsDetailsDefaultModeScreensWrapper", ( ) => {
describe( "when showSavedMatch is true", ( ) => {
it(
"renders SavedMatchContainer when observation belongs to current user and is not synced",
( ) => {
const unsyncedLocalObservation = {
...mockLocalObservation,
wasSynced: jest.fn( () => false )
};
jest.spyOn( useLocalObservation, "default" ).mockImplementation( () => ( {
localObservation: unsyncedLocalObservation,
markDeletedLocally: jest.fn( ),
markViewedLocally: jest.fn( )
} ) );
renderComponent( <ObsDetailsDefaultModeScreensWrapper /> );
expect( screen.getByTestId( "SavedMatch.container" ) ).toBeTruthy( );
expect( screen.queryByTestId( "ObsDetails.container" ) ).toBeFalsy( );
}
);
it( "renders SavedMatchContainer when observation has no user and is not synced", ( ) => {
const unsyncedLocalObservationNoUser = factory( "LocalObservation", {
user: null,
uuid: "test-123",
wasSynced: jest.fn( () => false )
} );
jest.spyOn( useLocalObservation, "default" ).mockImplementation( () => ( {
localObservation: unsyncedLocalObservationNoUser,
markDeletedLocally: jest.fn( ),
markViewedLocally: jest.fn( )
} ) );
renderComponent( <ObsDetailsDefaultModeScreensWrapper /> );
expect( screen.getByTestId( "SavedMatch.container" ) ).toBeTruthy( );
expect( screen.queryByTestId( "ObsDetails.container" ) ).toBeFalsy( );
} );
} );
describe( "when showSavedMatch is false", ( ) => {
it( "renders ObsDetailsDefaultModeContainer when observation is synced", ( ) => {
const syncedLocalObservation = {
...mockLocalObservation,
wasSynced: jest.fn( () => true )
};
jest.spyOn( useLocalObservation, "default" ).mockImplementation( () => ( {
localObservation: syncedLocalObservation,
markDeletedLocally: jest.fn( ),
markViewedLocally: jest.fn( )
} ) );
renderComponent( <ObsDetailsDefaultModeScreensWrapper /> );
expect( screen.getByTestId( "ObsDetails.container" ) ).toBeTruthy( );
expect( screen.queryByTestId( "SavedMatch.container" ) ).toBeFalsy( );
} );
it(
"renders ObsDetailsDefaultModeContainer when observation does not belong to current user",
( ) => {
const otherUserObservation = factory( "LocalObservation", {
user: factory( "LocalUser", {
id: 456
} ),
uuid: "test-123",
wasSynced: jest.fn( () => false )
} );
jest.spyOn( useLocalObservation, "default" ).mockImplementation( () => ( {
localObservation: otherUserObservation,
markDeletedLocally: jest.fn( ),
markViewedLocally: jest.fn( )
} ) );
renderComponent( <ObsDetailsDefaultModeScreensWrapper /> );
expect( screen.getByTestId( "ObsDetails.container" ) ).toBeTruthy( );
expect( screen.queryByTestId( "SavedMatch.container" ) ).toBeFalsy( );
}
);
} );
} );