Search screen + test coverage (#26)

* Create Explore screen

* Add input field component

* Add input fields

* UI for list view in Explore; dropdown taxa picker

* UI for list view in Explore; dropdown taxa picker

* ObservationViews component is shared between Explore and My Observations

* Get tests passing with Explore + ObservationViews

* Add map view, iOS location permission whenInUse, and geolocation fetch

* Add RN permissions and geolocation to jest mocks

* Explore filters, testing, explore provider/navigation stack

* Crash fix for grid items with no observation photos

* Code cleanup; move fetch search results to shared folder

* Code cleanup; remove duplicate files

* Use shared hooks for search

* Remove more duplication

* Display search results, similar to on web

* Show a list of search results for users/taxa and allow toggling

* Consolidate pickers into a single component

* Move copyRealmSchema into Observation model

* Move copyRealmSchema into Observation model

* Move observation photo logic to Obs model

* Obs details fetches an observation from API instead of from realm/exploreList

* Rename hooks files with 'use' instead of 'fetch'

* Add user to observation schema

* Change realm keys from camelcase to snakecase with mapping

* Simplify model code

* Fix tests for ObsDetails

* Attempt to clean up ObsList code; move useObservations hook into provider

* Simplify copyRealmSchema code

* Code cleanup

* Add test coverage for Search

* Add testing for users search

* Add gitguardian pre-commit hook

* Update ggshield

* Testing ggshield

* Add .env back to gitignore
This commit is contained in:
Amanda Bullington authored and GitHub committed 2022-01-07 12:25:27 -08:00
1 parent 9ef85a6818
commit 846b73a861
19 files changed
+527 -19

No files matched your search

View File
Whitespace-only changes.
+7
View File
@@ -0,0 +1,7 @@
repos:
- repo: https://github.com/gitguardian/ggshield
rev: v1.10.7
hooks:
- id: ggshield
language_version: python3
stages: [commit]
+1 -1
View File
@@ -8,7 +8,7 @@ import { Image } from "react-native";
// this is a placeholder to get functionality working
import DropDownPicker from "react-native-dropdown-picker";
import useRemoteSearchResults from "./hooks/useRemoteSearchResults";
import useRemoteSearchResults from "../../sharedHooks/useRemoteSearchResults";
import { imageStyles, viewStyles } from "../../styles/explore/explore";
type Props = {
@@ -0,0 +1,50 @@
// flow
import React, { useState } from "react";
import type { Node } from "react";
// TODO: we'll probably need a custom dropdown picker which looks like a search bar
// and allows users to input immediately instead of first tapping the dropdown
// this is a placeholder to get functionality working
import DropDownPicker from "react-native-dropdown-picker";
import useFetchSearchResults from "../../sharedHooks/useRemoteSearchResults";
import { viewStyles } from "../../styles/explore/explore";
type Props = {
location: string,
search: string => { },
setPlaceId: number => { },
placeId: number
}
const PlacePicker = ( { location, search, setPlaceId, placeId }: Props ): Node => {
const places = useFetchSearchResults( location, "places" );
const [open, setOpen] = useState( false );
const items = places.map( place => {
return {
// TODO: match styling on the web
label: place.name,
// value needs to be place.uuid once that's returned from api v2
value: place.uuid
};
} );
return (
<DropDownPicker
open={open}
value={placeId}
items={items}
setOpen={setOpen}
setValue={setPlaceId}
searchable={true}
disableLocalSearch={true}
onChangeSearchText={search}
placeholder="Search places"
style={viewStyles.dropdown}
/>
);
};
export default PlacePicker;
@@ -0,0 +1,49 @@
// flow
import React, { useState } from "react";
import type { Node } from "react";
// TODO: we'll probably need a custom dropdown picker which looks like a search bar
// and allows users to input immediately instead of first tapping the dropdown
// this is a placeholder to get functionality working
import DropDownPicker from "react-native-dropdown-picker";
import useFetchSearchResults from "../../sharedHooks/useRemoteSearchResults";
import { viewStyles } from "../../styles/explore/explore";
type Props = {
searchTerm: string,
search: string => { },
setProjectId: number => { },
projectId: number
}
const ProjectPicker = ( { searchTerm, search, setProjectId, projectId }: Props ): Node => {
const autocomplete = useFetchSearchResults( searchTerm, "projects" );
const [open, setOpen] = useState( false );
const items = autocomplete.map( project => {
return {
// TODO: match styling on the web
label: project.title,
value: project.id
};
} );
return (
<DropDownPicker
open={open}
value={projectId}
items={items}
setOpen={setOpen}
setValue={setProjectId}
searchable={true}
disableLocalSearch={true}
onChangeSearchText={search}
placeholder="Search projects"
style={viewStyles.dropdown}
/>
);
};
export default ProjectPicker;
@@ -0,0 +1,52 @@
// flow
import React, { useState } from "react";
import { Image } from "react-native";
import type { Node } from "react";
// TODO: we'll probably need a custom dropdown picker which looks like a search bar
// and allows users to input immediately instead of first tapping the dropdown
// this is a placeholder to get functionality working
import DropDownPicker from "react-native-dropdown-picker";
import useFetchSearchResults from "../../sharedHooks/useRemoteSearchResults";
import { imageStyles, viewStyles } from "../../styles/explore/explore";
type Props = {
searchTerm: string,
search: string => { },
setTaxonId: number => { },
taxonId: number
}
const TaxaPicker = ( { searchTerm, search, setTaxonId, taxonId }: Props ): Node => {
const autocomplete = useFetchSearchResults( searchTerm, "taxa" );
const [open, setOpen] = useState( false );
const items = autocomplete.map( taxa => {
return {
// TODO: match styling on the web; only show matched_term if the common name isn't clearly
// linked to the search result
label: `${taxa.preferred_common_name} (${taxa.matched_term})`,
value: taxa.id,
icon: ( ) => <Image source={{ uri: taxa.default_photo.url }} style={imageStyles.pickerIcon} />
};
} );
return (
<DropDownPicker
open={open}
value={taxonId}
items={items}
setOpen={setOpen}
setValue={setTaxonId}
searchable={true}
disableLocalSearch={true}
onChangeSearchText={search}
placeholder="Search taxon"
style={viewStyles.dropdown}
/>
);
};
export default TaxaPicker;
@@ -0,0 +1,51 @@
// flow
import React, { useState } from "react";
// import { Image } from "react-native";
import type { Node } from "react";
// TODO: we'll probably need a custom dropdown picker which looks like a search bar
// and allows users to input immediately instead of first tapping the dropdown
// this is a placeholder to get functionality working
import DropDownPicker from "react-native-dropdown-picker";
import useFetchSearchResults from "../../sharedHooks/useRemoteSearchResults";
import { viewStyles } from "../../styles/explore/explore";
type Props = {
searchTerm: string,
search: string => { },
setUserId: number => { },
userId: number
}
const UserPicker = ( { searchTerm, search, setUserId, userId }: Props ): Node => {
const autocomplete = useFetchSearchResults( searchTerm, "users" );
const [open, setOpen] = useState( false );
const items = autocomplete.map( user => {
return {
// TODO: match styling on the web
label: user.login,
value: user.id
// icon: ( ) => <Image source={{ uri: taxa.default_photo.url }} style={imageStyles.pickerIcon} />
};
} );
return (
<DropDownPicker
open={open}
value={userId}
items={items}
setOpen={setOpen}
setValue={setUserId}
searchable={true}
disableLocalSearch={true}
onChangeSearchText={search}
placeholder="Search users"
style={viewStyles.dropdown}
/>
);
};
export default UserPicker;
@@ -16,14 +16,15 @@ const useObservation = ( uuid: string ): Object => {
const realmRef = useRef( null );
const openObservationFromRealm = useCallback( async ( ) => {
const realm = await Realm.open( realmConfig );
realmRef.current = realm;
try {
const realm = await Realm.open( realmConfig );
realmRef.current = realm;
const obs = realm.objectForPrimaryKey( "Observation", uuid );
setObservation( obs );
}
catch ( err ) {
console.error( "Error opening realm: ", err.message );
console.error( `Error finding Observation with primary key: ${uuid} `, err.message );
}
}, [realmRef, uuid] );
@@ -57,7 +58,7 @@ const useObservation = ( uuid: string ): Object => {
setObservation( obs );
} catch ( e ) {
if ( !isCurrent ) { return; }
console.log( "Couldn't fetch observation:", e.message, );
console.log( `Couldn't fetch observation with uuid ${uuid}: `, e.message, );
}
};
+87
View File
@@ -0,0 +1,87 @@
// @flow
import * as React from "react";
import { FlatList, Pressable, Text, Image, View } from "react-native";
import { useNavigation } from "@react-navigation/native";
import ViewWithFooter from "../SharedComponents/ViewWithFooter";
import useFetchSearchResults from "../../sharedHooks/useRemoteSearchResults";
import InputField from "../SharedComponents/InputField";
import { viewStyles, imageStyles } from "../../styles/search/search";
const Search = ( ): React.Node => {
const navigation = useNavigation( );
const [q, setQ] = React.useState( "" );
const [queryType, setQueryType] = React.useState( "taxa" );
// choose users or taxa
const list = useFetchSearchResults( q, queryType );
const renderItem = ( { item } ) => {
// TODO: make sure TaxonDetails navigates back to Search
// instead of defaulting back to ObsList (first item in stack)
const navToTaxonDetails = ( ) => navigation.navigate( "TaxonDetails", { id: item.id } );
const navToUserProfile = ( ) => navigation.navigate( "UserProfile", { userId: item.id } );
if ( queryType === "taxa" ) {
const imageUrl = ( item && item.default_photo ) && { uri: item.default_photo.square_url };
return (
<Pressable
onPress={navToTaxonDetails}
style={viewStyles.row}
testID={`Search.taxa.${item.id}`}
>
<Image source={imageUrl} style={imageStyles.squareImage} testID={`Search.${item.id}.photo`} />
<Text>{`${item.preferred_common_name} (${item.rank} ${item.name})`}</Text>
</Pressable>
);
} else {
return (
<Pressable
onPress={navToUserProfile}
style={viewStyles.row}
testID={`Search.user.${item.login}`}
>
{/* TODO: add an empty icon when user doesn't have an icon */}
<Image source={{ uri: item.icon }} style={imageStyles.circularImage} testID={`Search.${item.login}.photo`}/>
<Text>{`${item.login} (${item.name})`}</Text>
</Pressable>
);
}
};
const setTaxaSearch = ( ) => setQueryType( "taxa" );
const setUserSearch = ( ) => setQueryType( "users" );
return (
<ViewWithFooter>
<View style={viewStyles.toggleRow}>
<Pressable
onPress={setTaxaSearch}
testID="Search.taxa"
accessibilityRole="button"
>
<Text>search taxa</Text>
</Pressable>
<Pressable
onPress={setUserSearch}
testID="Search.users"
accessibilityRole="button"
>
<Text>search users</Text>
</Pressable>
</View>
<InputField
handleTextChange={setQ}
placeholder={queryType === "taxa" ? "search for taxa" : "search for users"}
text={q}
type="none"
/>
<FlatList
data={list}
renderItem={renderItem}
testID="Search.listView"
/>
</ViewWithFooter>
);
};
export default Search;
+2 -6
View File
@@ -3,14 +3,10 @@ import Taxon from "./Taxon";
class Identification {
static copyRealmSchema( id ) {
return {
uuid: id.uuid,
body: id.body,
category: id.category,
...id,
createdAt: id.created_at,
id: id.id,
taxon: Taxon.mapApiToRealm( id.taxon ),
user: User.mapApiToRealm( id.user ),
vision: id.vision
user: User.mapApiToRealm( id.user )
};
}
+1 -2
View File
@@ -24,10 +24,9 @@ class Observation {
const user = User.mapApiToRealm( obs.user );
return {
uuid: obs.uuid,
...obs,
comments: comments || [],
createdAt: obs.created_at,
description: obs.description,
identifications: identifications || [],
latitude: obs.geojson ? obs.geojson.coordinates[1] : null,
longitude: obs.geojson ? obs.geojson.coordinates[0] : null,
+2 -4
View File
@@ -2,11 +2,9 @@ import Photo from "./Photo";
class Taxon {
static copyRealmSchema( taxon ) {
return {
...taxon,
default_photo: Photo.mapApiToRealm( taxon.default_photo ),
id: taxon.id,
name: taxon.name,
preferredCommonName: taxon.preferred_common_name,
rank: taxon.rank
preferredCommonName: taxon.preferred_common_name
};
}
+2 -1
View File
@@ -7,6 +7,7 @@ import { createDrawerNavigator } from "@react-navigation/drawer";
import PlaceholderComponent from "../components/PlaceholderComponent";
import MyObservationsStackNavigator from "./myObservationsStackNavigation";
import ExploreStackNavigator from "./exploreStackNavigation";
import Search from "../components/Search/Search";
// this removes the default hamburger menu from header
const screenOptions = { headerLeft: ( ) => <></> };
@@ -31,7 +32,7 @@ const App = ( ): React.Node => (
options={hideHeader}
/>
<Drawer.Screen name="missions/seen nearby" component={PlaceholderComponent} />
<Drawer.Screen name="search" component={PlaceholderComponent} />
<Drawer.Screen name="search" component={Search} />
<Drawer.Screen name="identify" component={PlaceholderComponent} />
<Drawer.Screen name="following (dashboard)" component={PlaceholderComponent} />
<Drawer.Screen name="impact" component={PlaceholderComponent} />
-1
View File
@@ -85,7 +85,6 @@ const useObservations = ( ): boolean => {
setLoading( false );
if ( !isCurrent ) { return; }
console.log( "Couldn't fetch observations:", e.message, );
console.trace( e );
}
};
+44
View File
@@ -0,0 +1,44 @@
// @flow
import { useEffect, useState } from "react";
import inatjs from "inaturalistjs";
// const FIELDS = {
// record: {
// name: true
// }
// };
const useRemoteSearchResults = ( q: string, sources: string ): Array<Object> => {
const [searchResults, setSearchResults] = useState( [] );
useEffect( ( ) => {
let isCurrent = true;
const fetchSearchResults = async ( ) => {
try {
const params = {
per_page: 10,
q,
// TODO: get fields param working
sources
};
const response = await inatjs.search( params );
const results = response.results.map( result => result.record );
if ( !isCurrent ) { return; }
setSearchResults( results );
} catch ( e ) {
if ( !isCurrent ) { return; }
console.log( `Couldn't fetch search results with sources ${sources}:`, e.message, );
}
};
fetchSearchResults( );
return ( ) => {
isCurrent = false;
};
}, [q, sources] );
return searchResults;
};
export default useRemoteSearchResults;
+48
View File
@@ -0,0 +1,48 @@
// @flow strict-local
import { StyleSheet } from "react-native";
import type { ImageStyleProp, TextStyleProp, ViewStyleProp } from "react-native/Libraries/StyleSheet/StyleSheet";
import { colors } from "../global";
const viewStyles: { [string]: ViewStyleProp } = StyleSheet.create( {
row: {
flexDirection: "row",
flexWrap: "nowrap",
alignItems: "center",
paddingVertical: 5,
borderBottomColor: colors.gray,
borderBottomWidth: 1
},
toggleRow: {
flexDirection: "row",
flexWrap: "nowrap",
justifyContent: "space-around"
}
} );
const textStyles: { [string]: TextStyleProp } = StyleSheet.create( {
text: { }
} );
const imageWidth = 40;
const imageStyles: { [string]: ImageStyleProp } = StyleSheet.create( {
circularImage: {
width: imageWidth,
height: imageWidth,
borderRadius: 50,
marginRight: 10
},
squareImage: {
width: imageWidth,
height: imageWidth,
marginRight: 10
}
} );
export {
imageStyles,
textStyles,
viewStyles
};
@@ -65,6 +65,7 @@ test( "navigates to observer profile on button press", ( ) => {
test( "navigates to identifier profile on button press", ( ) => {
const { getByTestId } = renderObsDetails( );
fireEvent.press( getByTestId( `ObsDetails.identifier.${mockObservation.identifications[0].user.id}` ) );
expect( mockedNavigate ).toHaveBeenCalledWith( "UserProfile", {
userId: mockObservation.identifications[0].user.id
@@ -73,6 +74,7 @@ test( "navigates to identifier profile on button press", ( ) => {
test( "navigates to taxon details on button press", ( ) => {
const { getByTestId } = renderObsDetails( );
fireEvent.press( getByTestId( `ObsDetails.taxon.${mockObservation.taxon.id}` ) );
expect( mockedNavigate ).toHaveBeenCalledWith( "TaxonDetails", {
id: mockObservation.taxon.id
@@ -0,0 +1,61 @@
import React from "react";
import { render, fireEvent } from "@testing-library/react-native";
import { NavigationContainer } from "@react-navigation/native";
import factory from "../../../factory";
import Search from "../../../../src/components/Search/Search";
const testTaxaList = [
factory( "RemoteTaxon" ),
factory( "RemoteTaxon" ),
factory( "RemoteTaxon" )
];
const mockExpected = testTaxaList;
jest.mock( "../../../../src/sharedHooks/useRemoteSearchResults", ( ) => ( {
__esModule: true,
default: ( ) => mockExpected
} ) );
const mockedNavigate = jest.fn( );
jest.mock( "@react-navigation/native", ( ) => {
const actualNav = jest.requireActual( "@react-navigation/native" );
return {
...actualNav,
useNavigation: ( ) => ( {
navigate: mockedNavigate
} )
};
} );
const renderSearch = ( ) => render(
<NavigationContainer>
<Search />
</NavigationContainer>
);
test( "renders taxon search results from API call", ( ) => {
const { getByTestId, getByText } = renderSearch( );
const taxon = testTaxaList[0];
const commonName = taxon.preferred_common_name;
expect( getByTestId( "Search.taxa" ) ).toBeTruthy( );
expect( getByTestId( `Search.${taxon.id}.photo` ).props.source ).toStrictEqual( { "uri": taxon.default_photo.square_url } );
// using RegExp to be able to search within a string
expect( getByText( new RegExp( commonName ) ) ).toBeTruthy( );
} );
// right now this is failing on react-native-modal, since there's a TouchableWithFeedback
// that allows the user to tap the backdrop and exit the modal
test.todo( "should not have accessibility errors" );
test( "navigates to TaxonDetails on button press", ( ) => {
const { getByTestId } = renderSearch( );
const taxon = testTaxaList[0];
fireEvent.press( getByTestId( `Search.taxa.${taxon.id}` ) );
expect( mockedNavigate ).toHaveBeenCalledWith( "TaxonDetails", { id: taxon.id } );
} );
@@ -0,0 +1,63 @@
import React from "react";
import { render, fireEvent } from "@testing-library/react-native";
import { NavigationContainer } from "@react-navigation/native";
import factory from "../../../factory";
import Search from "../../../../src/components/Search/Search";
// TODO: figure out how to clear jest mocks correctly or return a different
// value from jest mocks so these can all live in a single file?
const mockedNavigate = jest.fn( );
jest.mock( "@react-navigation/native", ( ) => {
const actualNav = jest.requireActual( "@react-navigation/native" );
return {
...actualNav,
useNavigation: ( ) => ( {
navigate: mockedNavigate
} )
};
} );
const renderSearch = ( ) => render(
<NavigationContainer>
<Search />
</NavigationContainer>
);
const testUserList = [
factory( "RemoteUser" )
];
const mockExpectedUsers = testUserList;
jest.mock( "../../../../src/sharedHooks/useRemoteSearchResults", ( ) => ( {
__esModule: true,
default: ( ) => mockExpectedUsers
} ) );
test( "displays user search results on button press", ( ) => {
const { getByTestId, getByText } = renderSearch( );
const user = testUserList[0];
const { login } = user;
const button = getByTestId( "Search.users" );
fireEvent.press( button );
expect( getByTestId( `Search.user.${login}` ) ).toBeTruthy( );
expect( getByTestId( `Search.${login}.photo` ).props.source ).toStrictEqual( { "uri": user.icon } );
expect( getByText( new RegExp( login ) ) ).toBeTruthy( );
} );
test( "navigates to user profile on button press", ( ) => {
const { getByTestId } = renderSearch( );
const user = testUserList[0];
const { login } = user;
const button = getByTestId( "Search.users" );
fireEvent.press( button );
fireEvent.press( getByTestId( `Search.user.${login}` ) );
expect( mockedNavigate ).toHaveBeenCalledWith( "UserProfile", { userId: user.id } );
} );