Pre-commit hooks; Realm updates

* Move realm object creation functions into realm classes

* Make all fields except for uuids optional in realm & update schemaVersion

* Add pre-commit hooks for linting

* Add eslint automatic fix line

* Testing pre-commit hook

* Still testing

* Tweak husky settings and remove lint-staged

* Add a Taxon realm shared by Observation and Identification; add listener for obs list changes

* Create new User realm model for Comments/Identifications

* Remove code comments from models

* Add loading wheel while ObsList is fetching initial data

* Move observations into stack within drawer navigator

* Remove unneeded code

* Look up existing obs using .objectForPrimaryKey method

* More code cleanup (naming, adding primary keys, etc.)

* Merge testing code from main; remove unused imports

* Add more factories and fix ObsCard tests

* Update tests to match updated realm models

* Move ObservationProvider to wrap MyObservationsStackNavigator instead of all navigation

* Remove code comments

* Add primary keys to User and Taxon schemas

* Update realm models to include ObservationPhotos

* Get integration test to pass with new realm schemas
This commit is contained in:
Amanda Bullington
2021-11-24 15:48:14 -08:00
committed by GitHub
parent 523da746f2
commit e206970c8c
38 changed files with 540 additions and 384 deletions

4
.husky/pre-commit Executable file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npm run lint

View File

@@ -4,6 +4,9 @@
1. Run `npm install`
2. Run `npx pod-install ios` or `cd ios && pod install` from the root directory
## Set up pre-commit hooks
1. We're using [Husky](https://typicode.github.io/husky/#/) to automatically run `eslint` before each commit. Run `npm run prepare` to install Husky locally.
## Run build
1. Run `npm start -- --reset-cache` (`npm start` works too, but resetting the cache each time makes for a lot less build issues)
2. Run `npm run ios` or `npm run android`

View File

@@ -4,7 +4,7 @@ import "react-native-gesture-handler";
import {AppRegistry} from "react-native";
import inatjs from "inaturalistjs";
import App from "./src/navigation/stackNavigation";
import App from "./src/navigation/rootNavigation";
import {name as appName} from "./app.json";
inatjs.setConfig( {

58
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.0.1",
"dependencies": {
"@react-navigation/drawer": "^6.1.8",
"@react-navigation/elements": "^1.2.1",
"@react-navigation/native": "^6.0.6",
"@react-navigation/native-stack": "^6.2.4",
"inaturalistjs": "github:inaturalist/inaturalistjs",
@@ -40,6 +41,7 @@
"factoria": "^3.2.2",
"faker": "^5.5.3",
"flow-bin": "^0.149.0",
"husky": "^7.0.4",
"jest": "^26.6.3",
"metro-react-native-babel-preset": "^0.66.0",
"react-native-accessibility-engine": "^0.6.0",
@@ -3703,7 +3705,7 @@
"react-native-screens": ">= 3.0.0"
}
},
"node_modules/@react-navigation/drawer/node_modules/@react-navigation/elements": {
"node_modules/@react-navigation/elements": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.2.1.tgz",
"integrity": "sha512-EnmAbKMsptrliRKf95rdgS6BhMjML+mIns06+G1Vdih6BrEo7/0iytThUv3WBf99AI76dyEq/cqLUwHPiFzXWg==",
@@ -3744,17 +3746,6 @@
"react-native-screens": ">= 3.0.0"
}
},
"node_modules/@react-navigation/native-stack/node_modules/@react-navigation/elements": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.2.1.tgz",
"integrity": "sha512-EnmAbKMsptrliRKf95rdgS6BhMjML+mIns06+G1Vdih6BrEo7/0iytThUv3WBf99AI76dyEq/cqLUwHPiFzXWg==",
"peerDependencies": {
"@react-navigation/native": "^6.0.0",
"react": "*",
"react-native": "*",
"react-native-safe-area-context": ">= 3.0.0"
}
},
"node_modules/@react-navigation/native/node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -8119,6 +8110,21 @@
"node": ">=8.12.0"
}
},
"node_modules/husky": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/husky/-/husky-7.0.4.tgz",
"integrity": "sha512-vbaCKN2QLtP/vD4yvs6iz6hBEo6wkSzs8HpRah1Z6aGmF2KW5PdYuAd7uX5a+OyBZHBhd+TFLqgjUgytQr4RvQ==",
"dev": true,
"bin": {
"husky": "lib/bin.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/hyphenate-style-name": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz",
@@ -18396,16 +18402,14 @@
"@react-navigation/elements": "^1.2.1",
"color": "^3.1.3",
"warn-once": "^0.1.0"
},
"dependencies": {
"@react-navigation/elements": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.2.1.tgz",
"integrity": "sha512-EnmAbKMsptrliRKf95rdgS6BhMjML+mIns06+G1Vdih6BrEo7/0iytThUv3WBf99AI76dyEq/cqLUwHPiFzXWg==",
"requires": {}
}
}
},
"@react-navigation/elements": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.2.1.tgz",
"integrity": "sha512-EnmAbKMsptrliRKf95rdgS6BhMjML+mIns06+G1Vdih6BrEo7/0iytThUv3WBf99AI76dyEq/cqLUwHPiFzXWg==",
"requires": {}
},
"@react-navigation/native": {
"version": "6.0.6",
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-6.0.6.tgz",
@@ -18430,14 +18434,6 @@
"requires": {
"@react-navigation/elements": "^1.2.1",
"warn-once": "^0.1.0"
},
"dependencies": {
"@react-navigation/elements": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.2.1.tgz",
"integrity": "sha512-EnmAbKMsptrliRKf95rdgS6BhMjML+mIns06+G1Vdih6BrEo7/0iytThUv3WBf99AI76dyEq/cqLUwHPiFzXWg==",
"requires": {}
}
}
},
"@react-navigation/routers": {
@@ -21798,6 +21794,12 @@
"integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==",
"dev": true
},
"husky": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/husky/-/husky-7.0.4.tgz",
"integrity": "sha512-vbaCKN2QLtP/vD4yvs6iz6hBEo6wkSzs8HpRah1Z6aGmF2KW5PdYuAd7uX5a+OyBZHBhd+TFLqgjUgytQr4RvQ==",
"dev": true
},
"hyphenate-style-name": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz",

View File

@@ -7,10 +7,12 @@
"ios": "react-native run-ios --simulator='iPhone 13'",
"start": "react-native start",
"test": "jest",
"lint": "eslint ."
"lint": "eslint .",
"prepare": "husky install"
},
"dependencies": {
"@react-navigation/drawer": "^6.1.8",
"@react-navigation/elements": "^1.2.1",
"@react-navigation/native": "^6.0.6",
"@react-navigation/native-stack": "^6.2.4",
"inaturalistjs": "github:inaturalist/inaturalistjs",
@@ -42,6 +44,7 @@
"factoria": "^3.2.2",
"faker": "^5.5.3",
"flow-bin": "^0.149.0",
"husky": "^7.0.4",
"jest": "^26.6.3",
"metro-react-native-babel-preset": "^0.66.0",
"react-native-accessibility-engine": "^0.6.0",

View File

@@ -12,14 +12,15 @@ type Props = {
}
const ActivityTab = ( { ids }: Props ): React.Node => ids.map( id => {
const taxon = id.taxon;
// this should all perform similarly to the activity tab on web
// https://github.com/inaturalist/inaturalist/blob/df6572008f60845b8ef5972a92a9afbde6f67829/app/webpack/observations/show/components/activity_item.jsx
return (
<View key={id.uuid}>
<View style={viewStyles.userProfileRow}>
<View style={viewStyles.userProfileRow}>
<UserIcon uri={id.userIcon} />
<Text>{`@${id.userLogin}`}</Text>
<UserIcon uri={id.user.iconUrl} />
<Text>{`@${id.user.login}`}</Text>
</View>
<Text>{id.body}</Text>
{id.vision && <Text>vision</Text>}
@@ -27,10 +28,10 @@ const ActivityTab = ( { ids }: Props ): React.Node => ids.map( id => {
{id.createdAt && <Text>time (ago)</Text>}
</View>
<View style={viewStyles.speciesDetailRow}>
<SmallSquareImage uri={id.taxonPhoto} />
<SmallSquareImage uri={taxon.defaultPhotoSquareUrl} />
<View>
<Text style={textStyles.commonNameText}>{id.commonName}</Text>
<Text style={textStyles.scientificNameText}>{id.rank} {id.name}</Text>
<Text style={textStyles.commonNameText}>{taxon.preferredCommonName}</Text>
<Text style={textStyles.scientificNameText}>{taxon.rank} {taxon.name}</Text>
</View>
</View>
</View>

View File

@@ -8,7 +8,6 @@ import { ScrollView } from "react-native-gesture-handler";
import { useNavigation } from "@react-navigation/core";
import { viewStyles, textStyles } from "../../styles/obsDetails";
// import useFetchObsDetails from "./hooks/fetchObsDetails";
import useFetchObsDetailsFromRealm from "./hooks/fetchObsFromRealm";
import ActivityTab from "./ActivityTab";
import UserIcon from "../SharedComponents/UserIcon";
@@ -27,20 +26,24 @@ const ObsDetails = ( ): Node => {
const navToUserProfile = ( ) => navigation.navigate( "UserProfile" );
const ids = observation && observation.identifications;
const photos = observation && observation.photos;
const photos = observation && observation.observationPhotos;
const showActivityTab = ( ) => setTab( 0 );
const showDataTab = ( ) => setTab( 1 );
if ( !observation ) { return null; }
const taxon = observation.taxon;
return (
<ViewWithFooter>
<ScrollView>
<View style={viewStyles.userProfileRow}>
<Pressable style={viewStyles.userProfileRow} onPress={navToUserProfile}>
<UserIcon uri={observation.userProfilePhoto} />
<Text>{`@${observation.userLogin}`}</Text>
{/* TODO: fill user icon in with saved current user icon or icon from another user API call */}
<UserIcon uri={null} />
{/* TODO: fill in text with saved current user login or login from another user API call */}
<Text>@username</Text>
</Pressable>
<Text>{observation.createdAt}</Text>
</View>
@@ -48,15 +51,15 @@ const ObsDetails = ( ): Node => {
<PhotoScroll photos={photos} />
</View>
<View style={viewStyles.row}>
<Image source={{ uri: observation.userPhoto }} style={viewStyles.imageBackground} />
<Image source={{ uri: taxon.defaultPhotoSquareUrl }} style={viewStyles.imageBackground} />
<View style={viewStyles.obsDetailsColumn}>
<Text style={textStyles.text}>{observation.taxonRank}</Text>
<Text style={textStyles.commonNameText}>{observation.commonName}</Text>
<Text style={textStyles.scientificNameText}>scientific name</Text>
<Text style={textStyles.text}>{taxon.rank}</Text>
<Text style={textStyles.commonNameText}>{taxon.preferredCommonName}</Text>
<Text style={textStyles.scientificNameText}>{taxon.name}</Text>
</View>
<View>
<Text style={textStyles.text}>{observation.identificationCount}</Text>
<Text style={textStyles.text}>{observation.commentCount}</Text>
<Text style={textStyles.text}>{observation.identifications.length}</Text>
<Text style={textStyles.text}>{observation.comments.length}</Text>
<Text style={textStyles.text}>{observation.qualityGrade}</Text>
</View>
</View>

View File

@@ -12,13 +12,14 @@ type Props = {
const PhotoScroll = ( { photos }: Props ): React.Node => {
const renderImage = ( { item } ) => {
let photoUrl = item.url.replace( "square", "large" );
const photo = item.photo;
let photoUrl = photo.url.replace( "square", "large" );
return (
<>
<Image source={{ uri: photoUrl }} style={imageStyles.fullWidthImage} />
<Pressable>
<Text style={textStyles.license}>{item.licenseCode}</Text>
<Text style={textStyles.license}>{photo.licenseCode}</Text>
</Pressable>
</>
);

View File

@@ -3,12 +3,12 @@
import * as React from "react";
import { Text, Pressable } from "react-native";
import { viewStyles } from "../../styles/observations/obsListRightHeader";
import { viewStyles } from "../../styles/observations/messagesIcon";
const ObsListRightHeader = ( ): React.Node => (
const MessagesIcon = ( ): React.Node => (
<Pressable onPress={( ) => console.log( "navigate to messages" )} style={viewStyles.messages}>
<Text>messages</Text>
</Pressable>
);
export default ObsListRightHeader;
export default MessagesIcon;

View File

@@ -20,18 +20,18 @@ const ObsCard = ( { item, handlePress }: Props ): Node => (
accessibilityLabel="Navigate to observation details screen"
>
<Image
source={{ uri: item.userPhoto }}
source={{ uri: item.observationPhotos[0].photo.url }}
style={viewStyles.imageBackground}
testID="ObsList.photo"
/>
<View style={viewStyles.obsDetailsColumn}>
<Text style={textStyles.text}>{item.commonName}</Text>
<Text style={textStyles.text}>{item.taxon.preferredCommonName}</Text>
<Text style={textStyles.text}>{item.placeGuess}</Text>
<Text style={textStyles.text}>{item.timeObservedAt}</Text>
</View>
<View>
<Text style={textStyles.text}>{item.identificationCount}</Text>
<Text style={textStyles.text} testID="ObsList.obsCard.commentCount">{item.commentCount}</Text>
<Text style={textStyles.text}>{item.identifications.length}</Text>
<Text style={textStyles.text} testID="ObsList.obsCard.commentCount">{item.comments.length}</Text>
<Text style={textStyles.text}>{item.qualityGrade}</Text>
</View>
</Pressable>

View File

@@ -1,7 +1,7 @@
// @flow
import React, { useContext, useEffect } from "react";
import { FlatList } from "react-native";
import { FlatList, ActivityIndicator } from "react-native";
import { useNavigation } from "@react-navigation/native";
import type { Node } from "react";
@@ -23,7 +23,7 @@ const ObsList = ( ): Node => {
// (and anytime you save while in debug - hot reloading mode )
// this will eventually go in a sync button / pull-from-top gesture
// instead of automatically fetching every time the component loads
useFetchObservations( );
const loading = useFetchObservations( );
const extractKey = item => item.uuid;
const renderItem = ( { item } ) => <ObsCard item={item} handlePress={navToObsDetails} />;
@@ -43,13 +43,18 @@ const ObsList = ( ): Node => {
return (
<ViewWithFooter>
<FlatList
data={observationList}
keyExtractor={extractKey}
renderItem={renderItem}
testID="ObsList.myObservations"
ListEmptyComponent={renderEmptyState}
/>
{loading
? <ActivityIndicator />
: (
<FlatList
data={observationList}
keyExtractor={extractKey}
renderItem={renderItem}
testID="ObsList.myObservations"
ListEmptyComponent={renderEmptyState}
/>
)
}
</ViewWithFooter>
);
};

View File

@@ -1,41 +1,102 @@
// @flow
import { useEffect, useMemo, useCallback, useRef, useState } from "react";
import { useEffect, useCallback, useRef, useState } from "react";
import inatjs from "inaturalistjs";
import Realm from "realm";
import realmConfig from "../../../models/index";
import Observation from "../../../models/Observation";
import Taxon from "../../../models/Taxon";
const useFetchObservations = ( ): Array<Object> => {
const [observations, setObservations] = useState( [] );
const USER_FIELDS = {
icon_url: true,
id: true,
login: true,
name: true
};
const TAXON_FIELDS = {
default_photo: {
square_url: true
},
iconic_taxon_name: true,
name: true,
preferred_common_name: true,
rank: true,
rank_level: true
};
const ID_FIELDS = {
body: true,
category: true,
created_at: true,
current: true,
disagreement: true,
taxon: TAXON_FIELDS,
updated_at: true,
user: Object.assign( { }, USER_FIELDS, { id: true } ),
uuid: true,
vision: true
};
const PHOTO_FIELDS = {
id: true,
attribution: true,
license_code: true,
url: true
};
const COMMENT_FIELDS = {
body: true,
created_at: true,
id: true,
user: USER_FIELDS
};
const OBSERVATION_PHOTOS_FIELDS = {
id: true,
photo: PHOTO_FIELDS,
position: true,
uuid: true
};
const FIELDS = {
comments_count: true,
comments: COMMENT_FIELDS,
created_at: true,
description: true,
geojson: true,
identifications: ID_FIELDS,
latitude: true,
location: true,
longitude: true,
observation_photos: OBSERVATION_PHOTOS_FIELDS,
photos: PHOTO_FIELDS,
place_guess: true,
quality_grade: true,
taxon: TAXON_FIELDS,
time_observed_at: true,
user: USER_FIELDS
};
const useFetchObservations = ( ): boolean => {
const [loading, setLoading] = useState( false );
const realmRef = useRef( null );
const subscriptionRef = useRef( null );
const openRealm = useCallback( async ( ) => {
try {
const realm = await Realm.open( realmConfig );
realmRef.current = realm;
const localObservations = realm.objects( "Observation" );
if ( localObservations?.length ) {
setObservations( localObservations );
}
subscriptionRef.current = localObservations;
}
catch ( err ) {
console.error( "Error opening realm: ", err.message );
}
}, [realmRef, setObservations] );
}, [realmRef] );
const closeRealm = useCallback( ( ) => {
const subscription = subscriptionRef.current;
subscription?.removeAllListeners( );
subscriptionRef.current = null;
const realm = realmRef.current;
realm?.close( );
realmRef.current = null;
setObservations( [] );
}, [realmRef] );
useEffect( ( ) => {
@@ -45,194 +106,34 @@ const useFetchObservations = ( ): Array<Object> => {
return closeRealm;
}, [openRealm, closeRealm] );
const FIELDS = useMemo( ( ) => {
const USER_FIELDS = {
icon_url: true,
id: true,
login: true,
name: true
};
const TAXON_FIELDS = {
default_photo: {
square_url: true
},
iconic_taxon_name: true,
name: true,
preferred_common_name: true,
rank: true,
rank_level: true
};
const ID_FIELDS = {
body: true,
category: true,
created_at: true,
current: true,
disagreement: true,
taxon: TAXON_FIELDS,
updated_at: true,
user: Object.assign( { }, USER_FIELDS, { id: true } ),
uuid: true,
vision: true
};
const PHOTO_FIELDS = {
id: true,
attribution: true,
license_code: true,
url: true
};
const COMMENT_FIELDS = {
body: true,
created_at: true,
id: true,
user: USER_FIELDS
};
return {
comments_count: true,
comments: COMMENT_FIELDS,
created_at: true,
description: true,
geojson: true,
identifications: ID_FIELDS,
latitude: true,
location: true,
longitude: true,
photos: PHOTO_FIELDS,
place_guess: true,
quality_grade: true,
taxon: TAXON_FIELDS,
time_observed_at: true,
user: USER_FIELDS
};
}, [] );
const createPhotoForRealm = ( photo ) => ( {
id: photo.id,
attribution: photo.attribution,
licenseCode: photo.license_code,
url: photo.url
} );
const createIdentificationForRealm = ( id ) => ( {
uuid: id.uuid,
body: id.body,
category: id.category,
commonName: id.taxon.preferred_common_name,
createdAt: id.created_at,
id: id.id,
name: id.taxon.name,
rank: id.taxon.rank,
taxonPhoto: id.taxon.default_photo.square_url,
userIcon: id.user.icon_url,
userLogin: id.user.login,
vision: id.vision
} );
const createCommentForRealm = ( id ) => ( {
body: id.body,
createdAt: id.created_at,
id: id.id,
user: id.user.login
} );
const createLinkedIdentifications = useCallback( ( obs ) => {
const identifications = [];
if ( obs.identifications.length > 0 ) {
obs.identifications.forEach( ( id ) => {
const linkedIdentification = createIdentificationForRealm( id );
identifications.push( linkedIdentification );
} );
}
return identifications;
}, [] );
const createLinkedPhotos = useCallback( ( obs ) => {
const photos = [];
if ( obs.photos.length > 0 ) {
obs.photos.forEach( ( photo ) => {
const linkedPhoto = createPhotoForRealm( photo );
photos.push( linkedPhoto );
} );
}
return photos;
}, [] );
const createLinkedComments = useCallback( ( obs ) => {
const comments = [];
if ( obs.comments.length > 0 ) {
obs.comments.forEach( ( photo ) => {
const linkedComment = createCommentForRealm( photo );
comments.push( linkedComment );
} );
}
return comments;
}, [] );
const createObservationForRealm = useCallback( ( obs ) => {
const identifications = createLinkedIdentifications( obs );
const photos = createLinkedPhotos( obs );
const comments = createLinkedComments( obs );
return {
uuid: obs.uuid,
commentCount: obs.comment_count || 0,
comments,
commonName: obs.taxon.preferred_common_name,
createdAt: obs.created_at,
description: obs.description,
identificationCount: obs.identifications.length,
identifications,
// obs detail on web says geojson coords are preferred over lat/long
// https://github.com/inaturalist/inaturalist/blob/df6572008f60845b8ef5972a92a9afbde6f67829/app/webpack/observations/show/ducks/observation.js#L145
latitude: obs.geojson.coordinates[1],
location: obs.location,
longitude: obs.geojson.coordinates[0],
photos,
placeGuess: obs.place_guess,
qualityGrade: obs.quality_grade,
taxonRank: obs.taxon.rank,
timeObservedAt: obs.time_observed_at,
userProfilePhoto: obs.user.icon_url,
userLogin: obs.user.login,
userPhoto: obs.photos[0].url
};
}, [createLinkedIdentifications, createLinkedPhotos, createLinkedComments] );
const writeToDatabase = useCallback( ( results ) => {
if ( results.length === 0 ) {
return;
}
// Everything in the function passed to "realm.write" is a transaction and will
// hence succeed or fail together. A transcation is the smallest unit of transfer
// in Realm so we want to be mindful of how much we put into one single transaction
// and split them up if appropriate (more commonly seen server side). Since clients
// may occasionally be online during short time spans we want to increase the probability
// of sync participants to successfully sync everything in the transaction, otherwise
// no changes propagate and the transaction needs to start over when connectivity allows.
if ( results.length === 0 ) { return; }
const realm = realmRef.current;
results.forEach( obs => {
const newObs = createObservationForRealm( obs );
const newObs = Observation.createObservationForRealm( obs, realm );
realm?.write( ( ) => {
// Shouldn't the primary key in realm handle this?
const existingObs = realm.objects( "Observation" ).filtered( `uuid = '${obs.uuid}'` );
if ( existingObs.length > 0 ) {
const existingObs = realm.objectForPrimaryKey( "Observation", obs.uuid );
if ( existingObs !== undefined ) {
// TODO: modify existing objects when syncing from inatjs
return;
}
realm?.create( "Observation", newObs );
// need to append Taxon object to identifications after the Observation object
// has been created with its own Taxon object, otherwise will run into errors
// with realm trying to create a Taxon object with an existing primary key
obs.identifications.forEach( id => {
const identification = realm.objectForPrimaryKey( "Identification", id.uuid );
identification.taxon = Taxon.mapApiToRealm( id.taxon, realm );
} );
} );
} );
}, [createObservationForRealm] );
setLoading( false );
}, [] );
useEffect( ( ) => {
let isCurrent = true;
const fetchObservations = async ( ) => {
setLoading( true );
try {
const testUser = "albullington";
const params = {
@@ -245,6 +146,7 @@ const writeToDatabase = useCallback( ( results ) => {
if ( !isCurrent ) { return; }
writeToDatabase( results );
} catch ( e ) {
setLoading( false );
if ( !isCurrent ) { return; }
console.log( "Couldn't fetch observations:", e.message, );
}
@@ -254,9 +156,9 @@ const writeToDatabase = useCallback( ( results ) => {
return ( ) => {
isCurrent = false;
};
}, [FIELDS, writeToDatabase] );
}, [writeToDatabase] );
return observations;
return loading;
};
export default useFetchObservations;

View File

@@ -1,11 +1,21 @@
import User from "./User";
class Comment {
static mapApiToRealm( comment, realm ) {
return {
body: comment.body,
createdAt: comment.created_at,
id: comment.id,
user: User.mapApiToRealm( comment.user, realm )
};
}
static schema = {
name: "Comment",
properties: {
body: "string",
createdAt: "string",
id: "int",
user: "string",
body: "string?",
createdAt: "string?",
id: "int?",
user: "User?",
// this creates an inverse relationship so comments
// automatically keep track of which Observation they are assigned to
assignee: {

View File

@@ -1,19 +1,28 @@
import User from "./User";
class Identification {
static mapApiToRealm( id, realm ) {
return {
uuid: id.uuid,
body: id.body,
category: id.category,
createdAt: id.created_at,
id: id.id,
user: User.mapApiToRealm( id.user, realm ),
vision: id.vision
};
}
static schema = {
name: "Identification",
primaryKey: "uuid",
properties: {
uuid: "string",
body: "string?",
category: "string",
commonName: "string?",
createdAt: "string",
name: "string",
rank: "string",
taxonPhoto: "string",
userIcon: "string?",
userLogin: "string",
vision: "bool",
category: "string?",
createdAt: "string?",
taxon: "Taxon?",
user: "User?",
vision: "bool?",
// this creates an inverse relationship so identifications
// automatically keep track of which Observation they are assigned to
assignee: {

View File

@@ -1,27 +1,57 @@
import Comment from "./Comment";
import Identification from "./Identification";
import ObservationPhoto from "./ObservationPhoto";
import Taxon from "./Taxon";
class Observation {
static createObservationForRealm( obs, realm ) {
const createLinkedObjects = ( list, createFunction ) => {
if ( list.length === 0 ) { return; }
return list.map( item => {
return createFunction.mapApiToRealm( item, realm, "obs" );
} );
};
const taxon = Taxon.mapApiToRealm( obs.taxon, realm );
const observationPhotos = createLinkedObjects( obs.observation_photos, ObservationPhoto );
const comments = createLinkedObjects( obs.comments, Comment, realm );
const identifications = createLinkedObjects( obs.identifications, Identification );
return {
uuid: obs.uuid,
comments,
createdAt: obs.created_at,
description: obs.description,
identifications,
// obs detail on web says geojson coords are preferred over lat/long
// https://github.com/inaturalist/inaturalist/blob/df6572008f60845b8ef5972a92a9afbde6f67829/app/webpack/observations/show/ducks/observation.js#L145
latitude: obs.geojson.coordinates[1],
longitude: obs.geojson.coordinates[0],
observationPhotos,
// photos,
placeGuess: obs.place_guess,
qualityGrade: obs.quality_grade,
taxon,
timeObservedAt: obs.time_observed_at
};
}
static schema = {
name: "Observation",
primaryKey: "uuid",
properties: {
uuid: "string",
commentCount: "int",
comments: "Comment[]",
commonName: "string?",
createdAt: "string",
createdAt: "string?",
description: "string?",
identifications: "Identification[]",
identificationCount: "int",
latitude: "double?",
location: "string",
longitude: "double?",
photos: "Photo[]",
placeGuess: "string",
qualityGrade: "string",
taxonRank: "string",
timeObservedAt: "string",
userProfilePhoto: "string",
userLogin: "string",
userPhoto: "string"
observationPhotos: "ObservationPhoto[]",
placeGuess: "string?",
qualityGrade: "string?",
taxon: "Taxon?",
timeObservedAt: "string?"
}
}
}

View File

@@ -0,0 +1,32 @@
import Photo from "./Photo";
class ObservationPhoto {
static mapApiToRealm( observationPhoto ) {
return {
id: observationPhoto.id,
position: observationPhoto.position,
photo: Photo.mapApiToRealm( observationPhoto.photo ),
uuid: observationPhoto.uuid
};
}
static schema = {
name: "ObservationPhoto",
primaryKey: "uuid",
properties: {
uuid: "string",
id: "int?",
photo: "Photo?",
position: "int?",
// this creates an inverse relationship so observation photos
// automatically keep track of which Observation they are assigned to
assignee: {
type: "linkingObjects",
objectType: "Observation",
property: "observationPhotos"
}
}
}
}
export default ObservationPhoto;

View File

@@ -1,19 +1,21 @@
class Photo {
static mapApiToRealm( photo ) {
return {
id: photo.id,
attribution: photo.attribution,
licenseCode: photo.license_code,
url: photo.url
};
}
static schema = {
name: "Photo",
// need uuid to be primary key for photos that get uploaded?
properties: {
id: "int",
attribution: "string",
licenseCode: "string",
url: "string",
// this creates an inverse relationship so photos
// automatically keep track of which Observation they are assigned to
assignee: {
type: "linkingObjects",
objectType: "Observation",
property: "photos"
}
id: "int?",
attribution: "string?",
licenseCode: "string?",
url: "string?"
}
}
}

27
src/models/Taxon.js Normal file
View File

@@ -0,0 +1,27 @@
class Taxon {
static mapApiToRealm( taxon, realm ) {
const existingTaxon = realm.objectForPrimaryKey( "Taxon", taxon.id );
if ( existingTaxon ) { return existingTaxon; }
return {
defaultPhotoSquareUrl: taxon.default_photo.square_url,
id: taxon.id,
name: taxon.name,
preferredCommonName: taxon.preferred_common_name,
rank: taxon.rank
};
}
static schema = {
name: "Taxon",
primaryKey: "id",
properties: {
id: "int",
defaultPhotoSquareUrl: "string?",
name: "string?",
preferredCommonName: "string?",
rank: "string?"
}
}
}
export default Taxon;

25
src/models/User.js Normal file
View File

@@ -0,0 +1,25 @@
class User {
static mapApiToRealm( user, realm ) {
const existingUser = realm.objectForPrimaryKey( "User", user.id );
if ( existingUser ) { return existingUser; }
return {
id: user.id,
iconUrl: user.icon_url,
login: user.login,
name: user.name
};
}
static schema = {
name: "User",
primaryKey: "id",
properties: {
id: "int",
iconUrl: "string?",
login: "string?",
name: "string?"
}
}
}
export default User;

View File

@@ -3,15 +3,21 @@
import Comment from "./Comment";
import Identification from "./Identification";
import Observation from "./Observation";
import ObservationPhoto from "./ObservationPhoto";
import Photo from "./Photo";
import Taxon from "./Taxon";
import User from "./User";
export default {
schema: [
Comment,
Identification,
Observation,
Photo
ObservationPhoto,
Photo,
Taxon,
User
],
schemaVersion: 1,
schemaVersion: 2,
path: "db.realm"
};

View File

@@ -1,38 +0,0 @@
// @flow
import * as React from "react";
import { createDrawerNavigator } from "@react-navigation/drawer";
import ObsList from "../components/Observations/ObsList";
import PlaceholderComponent from "../components/PlaceholderComponent";
import ObsListRightHeader from "../components/Observations/ObsListRightHeader";
// this removes the default hamburger menu from header
const screenOptions = { headerLeft: ( ) => <></> };
const Drawer = createDrawerNavigator();
const DrawerNavigator = ( ): React.Node => (
<Drawer.Navigator screenOptions={screenOptions}>
<Drawer.Screen
name="my observations"
component={ObsList}
options={( { navigation } ) => ( {
headerRight: ( ) => <ObsListRightHeader />
} )}
/>
<Drawer.Screen name="missions/seen nearby" component={PlaceholderComponent} />
<Drawer.Screen name="search" component={PlaceholderComponent} />
<Drawer.Screen name="identify" component={PlaceholderComponent} />
<Drawer.Screen name="following (dashboard)" component={PlaceholderComponent} />
<Drawer.Screen name="impact" component={PlaceholderComponent} />
<Drawer.Screen name="projects" component={PlaceholderComponent} />
<Drawer.Screen name="guides" component={PlaceholderComponent} />
<Drawer.Screen name="about" component={PlaceholderComponent} />
<Drawer.Screen name="help/tutorials" component={PlaceholderComponent} />
<Drawer.Screen name="settings" component={PlaceholderComponent} />
<Drawer.Screen name="logout" component={PlaceholderComponent} />
</Drawer.Navigator>
);
export default DrawerNavigator;

View File

@@ -0,0 +1,49 @@
// @flow
import * as React from "react";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { HeaderBackButton } from "@react-navigation/elements";
import ObsList from "../components/Observations/ObsList";
import ObsDetails from "../components/ObsDetails/ObsDetails";
import UserProfile from "../components/UserProfile/UserProfile";
import MessagesIcon from "../components/Observations/MessagesIcon";
import ObservationProvider from "../providers/ObservationProvider";
const Stack = createNativeStackNavigator( );
const screenOptions = {
headerShown: true
};
const showBackButton = ( { navigation } ) => ( {
headerLeft: ( ) => <HeaderBackButton onPress={( ) => navigation.goBack( )} />
} );
const App = ( ): React.Node => (
// TODO: decide whether ObservationProvider needs to wrap both ObsList and ObsDetail
// or whether we should simply pass the uuid through navigation params
<ObservationProvider>
<Stack.Navigator screenOptions={screenOptions}>
<Stack.Screen
name="ObsList"
component={ObsList}
options={( { navigation } ) => ( {
headerRight: ( ) => <MessagesIcon />
} )}
/>
<Stack.Screen
name="ObsDetails"
component={ObsDetails}
options={showBackButton}
/>
<Stack.Screen
name="UserProfile"
component={UserProfile}
options={showBackButton}
/>
</Stack.Navigator>
</ObservationProvider>
);
export default App;

View File

@@ -0,0 +1,43 @@
// @flow
import * as React from "react";
import { NavigationContainer } from "@react-navigation/native";
import { createDrawerNavigator } from "@react-navigation/drawer";
import PlaceholderComponent from "../components/PlaceholderComponent";
import MyObservationsStackNavigator from "./myObservationsStackNavigation";
// this removes the default hamburger menu from header
const screenOptions = { headerLeft: ( ) => <></> };
const hideHeader = {
headerShown: false,
label: "my observations"
};
const Drawer = createDrawerNavigator( );
const App = ( ): React.Node => (
<NavigationContainer>
<Drawer.Navigator screenOptions={screenOptions} name="Drawer">
<Drawer.Screen
name="my observations"
component={MyObservationsStackNavigator}
options={hideHeader}
/>
<Drawer.Screen name="missions/seen nearby" component={PlaceholderComponent} />
<Drawer.Screen name="search" component={PlaceholderComponent} />
<Drawer.Screen name="identify" component={PlaceholderComponent} />
<Drawer.Screen name="following (dashboard)" component={PlaceholderComponent} />
<Drawer.Screen name="impact" component={PlaceholderComponent} />
<Drawer.Screen name="projects" component={PlaceholderComponent} />
<Drawer.Screen name="guides" component={PlaceholderComponent} />
<Drawer.Screen name="about" component={PlaceholderComponent} />
<Drawer.Screen name="help/tutorials" component={PlaceholderComponent} />
<Drawer.Screen name="settings" component={PlaceholderComponent} />
<Drawer.Screen name="logout" component={PlaceholderComponent} />
</Drawer.Navigator>
</NavigationContainer>
);
export default App;

View File

@@ -1,29 +0,0 @@
// @flow
import * as React from "react";
import { NavigationContainer } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import DrawerNavigator from "./drawerNavigation";
import ObsDetails from "../components/ObsDetails/ObsDetails";
import UserProfile from "../components/UserProfile/UserProfile";
import ObservationProvider from "../providers/ObservationProvider";
const Stack = createNativeStackNavigator( );
const screenOptions = { headerShown: false };
const App = ( ): React.Node => (
<NavigationContainer>
<ObservationProvider>
<Stack.Navigator screenOptions={screenOptions}>
<Stack.Screen name="Drawer" component={DrawerNavigator} />
<Stack.Screen name="ObsDetails" component={ObsDetails} options={{ headerShown: true }} />
<Stack.Screen name="UserProfile" component={UserProfile} options={{ headerShown: true }} />
</Stack.Navigator>
</ObservationProvider>
</NavigationContainer>
);
export default App;

View File

@@ -32,6 +32,9 @@ const ObservationProvider = ( { children }: Props ): Node => {
}
localObservations.addListener( ( ) => {
// changes object has properties including insertions, modifications, and deletions
// so we can decide when we need obslist to rerender here. otherwise, it will listen for all changes
// If you just pass localObservations you end up assigning a Results
// object to state instead of an array of observations. There's
// probably a better way...

View File

@@ -0,0 +1,8 @@
import factory, { define } from "factoria";
export default define( "LocalComment", faker => ( {
id: faker.datatype.number( ),
createdAt: "2019-09-09T08:28:05-08:00",
user: factory( "RemoteUser" ),
body: faker.lorem.sentence( )
} ) );

View File

@@ -0,0 +1,7 @@
import { define } from "factoria";
export default define( "LocalIdentification", faker => ( {
uuid: faker.datatype.number( ),
createdAt: "2017-09-09T08:28:05-08:00",
body: faker.lorem.sentence( )
} ) );

View File

@@ -1,14 +1,21 @@
import { define } from "factoria";
import factory, { define } from "factoria";
// TODO use faker for more of these dynamic values. Also, would we really
// store timeObservedAt like this in Realm?!
// TODO use faker for more of these dynamic values.
export default define( "LocalObservation", faker => ( {
uuid: faker.datatype.uuid( ),
userPhoto: faker.image.imageUrl( ),
commonName: "Insects",
comments: [
factory( "LocalComment" ),
factory( "LocalComment" ),
factory( "LocalComment" )
],
identifications: [
factory( "LocalIdentification" )
],
observationPhotos: [
factory( "LocalObservationPhoto" )
],
placeGuess: "SF",
timeObservedAt: "May 1, 2021",
identificationCount: 3,
commentCount: 0,
taxon: factory( "LocalTaxon" ),
timeObservedAt: "2021-05-09T07:27:05-06:00",
qualityGrade: "research"
} ) );

View File

@@ -0,0 +1,8 @@
import factory, { define } from "factoria";
export default define( "LocalObservationPhoto", faker => ( {
uuid: faker.datatype.uuid( ),
id: faker.datatype.number( ),
photo: factory( "LocalPhoto" ),
position: faker.datatype.number( )
} ) );

View File

@@ -0,0 +1,8 @@
import { define } from "factoria";
export default define( "LocalPhoto", faker => ( {
id: faker.datatype.number( ),
attribution: faker.lorem.sentence( ),
licenseCode: "cc-by-nc",
url: faker.image.imageUrl( )
} ) );

View File

@@ -0,0 +1,8 @@
import { define } from "factoria";
export default define( "LocalTaxon", faker => ( {
id: faker.datatype.number( ),
name: faker.name.firstName( ),
rank: "family",
preferredCommonName: faker.name.findName( )
} ) );

View File

@@ -8,8 +8,8 @@ export default define( "RemoteObservation", faker => ( {
uuid: faker.datatype.uuid( ),
user: factory( "RemoteUser" ),
identifications: [],
photos: [
factory( "RemotePhoto" )
observation_photos: [
factory( "RemoteObservationPhoto" )
],
comments: [],
taxon: factory( "RemoteTaxon" ),

View File

@@ -0,0 +1,8 @@
import factory, { define } from "factoria";
export default define( "RemoteObservationPhoto", faker => ( {
uuid: faker.datatype.uuid( ),
id: faker.datatype.number( ),
photo: factory( "RemotePhoto" ),
position: faker.datatype.number( )
} ) );

View File

@@ -1,7 +1,11 @@
import { define } from "factoria";
export default define( "RemoteTaxon", faker => ( {
id: faker.datatype.number( ),
name: faker.name.firstName( ),
rank: "genus",
preferred_common_name: faker.name.findName( )
preferred_common_name: faker.name.findName( ),
default_photo: {
square_url: faker.image.imageUrl( )
}
} ) );

View File

@@ -3,7 +3,7 @@
import React from "react";
import factory, { makeResponse } from "../factory";
import { render, waitFor, within, act } from "@testing-library/react-native";
import { render, waitFor, within } from "@testing-library/react-native";
import { NavigationContainer } from "@react-navigation/native";
import AccessibilityEngine from "react-native-accessibility-engine";
import ObsList from "../../src/components/Observations/ObsList";
@@ -24,7 +24,9 @@ const renderObsList = async ( ) => waitFor(
);
test( "renders the number of comments from remote response", async ( ) => {
const observations = [factory( "RemoteObservation", { place_guess: "foo", comment_count: 13 } )];
const observations = [factory( "RemoteObservation", { place_guess: "foo", comments: [
factory( "LocalComment" )
] } )];
inatjs.observations.search.mockResolvedValue( makeResponse( observations ) );
const { getByTestId } = await renderObsList( );
expect( inatjs.observations.search.mock.calls.length ).toBeGreaterThan( 0 );
@@ -32,7 +34,7 @@ test( "renders the number of comments from remote response", async ( ) => {
const card = getByTestId( `ObsList.obsCard.${obs.uuid}` );
expect( card ).toBeTruthy( );
const commentCount = within( card ).getByTestId( "ObsList.obsCard.commentCount" );
expect( commentCount.children[0] ).toEqual( obs.comment_count.toString( ) );
expect( commentCount.children[0] ).toEqual( obs.comments.length.toString( ) );
} );
test.todo( "only makes one concurrent request for observations at a time" );

View File

@@ -14,12 +14,12 @@ test( "renders text passed into observation card", ( ) => {
);
expect( getByTestId( `ObsList.obsCard.${testObservation.uuid}` ) ).toBeTruthy( );
expect( getByTestId( "ObsList.photo" ).props.source ).toStrictEqual( { "uri": testObservation.userPhoto } );
expect( getByText( testObservation.commonName ) ).toBeTruthy( );
expect( getByTestId( "ObsList.photo" ).props.source ).toStrictEqual( { "uri": testObservation.observationPhotos[0].photo.url } );
expect( getByText( testObservation.taxon.preferredCommonName ) ).toBeTruthy( );
expect( getByText( testObservation.placeGuess ) ).toBeTruthy( );
expect( getByText( testObservation.timeObservedAt ) ).toBeTruthy( );
expect( getByText( testObservation.commentCount.toString( ) ) ).toBeTruthy( );
expect( getByText( testObservation.identificationCount.toString( ) ) ).toBeTruthy( );
expect( getByText( testObservation.comments.length.toString( ) ) ).toBeTruthy( );
expect( getByText( testObservation.identifications.length.toString( ) ) ).toBeTruthy( );
expect( getByText( testObservation.qualityGrade ) ).toBeTruthy( );
} );

View File

@@ -1,5 +1,5 @@
import React from "react";
import { waitFor, render, within } from "@testing-library/react-native";
import { render, within } from "@testing-library/react-native";
import { NavigationContainer } from "@react-navigation/native";
import factory from "../../../factory";
import ObsList from "../../../../src/components/Observations/ObsList";
@@ -35,8 +35,11 @@ const renderObsList = ( ) => render(
);
it( "renders an observation", ( ) => {
// const observations = [factory( "LocalObservation", { commentCount: 11 } )];
const observations = [factory( "LocalObservation", { commentCount: 11 } )];
const observations = [factory( "LocalObservation", { comments: [
factory( "LocalComment" ),
factory( "LocalComment" ),
factory( "LocalComment" )
] } )];
// Mock the provided observations so we're just using our test data
mockObservationProviderWithObservations( observations );
const { getByTestId } = renderObsList( );
@@ -49,7 +52,7 @@ it( "renders an observation", ( ) => {
expect( card ).toBeTruthy( );
// Test that the card has the correct comment count
const commentCount = within( card ).getByTestId( "ObsList.obsCard.commentCount" );
expect( commentCount.children[0] ).toEqual( obs.commentCount.toString( ) );
expect( commentCount.children[0] ).toEqual( obs.comments.length.toString( ) );
} );
it( "renders multiple observations", async ( ) => {