Update obs list to use React Query, fetch observations via id_below (#273)

* Rewrite remote observation & update fetching using useQuery in ObsList
* Fix ObsList test by adding query provider
* Add padding to infinite scroll view indicator
* Only set next uuid for obslist if not still loading results from last api call
* Improve how ObsList works when observations don't fill screen
* Remove explore, explore provider, dropdown menu, and related screens; fix tests
* Move setIdBelow function into onEndReached
This commit is contained in:
Amanda Bullington
2022-12-09 12:17:26 -08:00
committed by GitHub
parent fb4bf79878
commit 8a92fca2e8
32 changed files with 288 additions and 1746 deletions

2
package-lock.json generated
View File

@@ -41,7 +41,7 @@
"linkifyjs": "^4.0.2",
"lodash": "^4.17.21",
"markdown-it": "^13.0.1",
"nativewind": "^2.0.10",
"nativewind": "^2.0.11",
"radio-buttons-react-native": "^1.0.4",
"react": "18.1.0",
"react-dom": "18.1.0",

View File

@@ -46,7 +46,7 @@
"linkifyjs": "^4.0.2",
"lodash": "^4.17.21",
"markdown-it": "^13.0.1",
"nativewind": "^2.0.10",
"nativewind": "^2.0.11",
"radio-buttons-react-native": "^1.0.4",
"react": "18.1.0",
"react-dom": "18.1.0",

View File

@@ -76,9 +76,9 @@ const REMOTE_OBSERVATION_PARAMS = {
const searchObservations = async ( params: Object = {}, opts: Object = {} ): Promise<any> => {
try {
const { results } = await inatjs.observations.search( { ...PARAMS, ...params }, opts );
return results;
return results || [];
} catch ( e ) {
return handleError( e );
return handleError( e, { throw: true } );
}
};
@@ -172,10 +172,23 @@ const createOrUpdateEvidence = async (
}
};
const fetchObservationUpdates = async (
params: Object = {},
opts: Object = {}
): Promise<?any> => {
try {
const { results } = await inatjs.observations.updates( params, opts );
return results;
} catch ( e ) {
return handleError( e, { throw: true } );
}
};
export {
createObservation,
createOrUpdateEvidence,
faveObservation,
fetchObservationUpdates,
fetchRemoteObservation,
markAsReviewed,
markObservationUpdatesViewed,

View File

@@ -1,63 +0,0 @@
// @flow
import { Text, View } from "components/styledComponents";
import { t } from "i18next";
import { ExploreContext } from "providers/contexts";
import type { Node } from "react";
import React, { useContext } from "react";
import { viewStyles } from "styles/explore/explore";
import DropdownPicker from "./DropdownPicker";
import FiltersIcon from "./FiltersIcon";
const Explore = ( ): Node => {
const {
exploreFilters,
setExploreFilters,
taxon,
setTaxon,
location,
setLocation
} = useContext( ExploreContext );
const setTaxonId = getValue => {
setExploreFilters( {
...exploreFilters,
taxon_id: getValue( )
} );
};
const setPlaceId = getValue => {
setExploreFilters( {
...exploreFilters,
place_id: getValue( )
} );
};
const taxonId = exploreFilters ? exploreFilters.taxon_id : null;
const placeId = exploreFilters ? exploreFilters.place_id : null;
return (
<View style={viewStyles.bottomCard}>
<Text>{t( "Explore" )}</Text>
<FiltersIcon />
<DropdownPicker
searchQuery={taxon}
setSearchQuery={setTaxon}
setValue={setTaxonId}
sources="taxa"
value={taxonId}
placeholder="Search-for-a-taxon"
/>
<DropdownPicker
searchQuery={location}
setSearchQuery={setLocation}
setValue={setPlaceId}
sources="places"
value={placeId}
placeholder="Search-for-a-location"
/>
</View>
);
};
export default Explore;

View File

@@ -1,129 +0,0 @@
// flow
import fetchSearchResults from "api/search";
import { t } from "i18next";
import type { Node } from "react";
import React from "react";
import { Image } from "react-native";
// 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 useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
import { imageStyles, viewStyles } from "styles/explore/explore";
import { useDebounce } from "use-debounce";
type Props = {
searchQuery: string,
setSearchQuery: string => { },
setValue: number => { },
sources: string,
value: number,
placeholder: string,
open: boolean,
onOpen: Function,
onClose: Function,
zIndex: number,
zIndexInverse: number
}
const DropdownPicker = ( {
searchQuery,
setSearchQuery,
setValue,
sources,
value,
placeholder,
open,
onOpen,
onClose,
zIndex,
zIndexInverse
}: Props ): Node => {
// So we'll start searching only once the user finished typing
const [finalSearch] = useDebounce( searchQuery, 500 );
const {
data: searchResults
} = useAuthenticatedQuery(
["fetchSearchResults", finalSearch],
optsWithAuth => fetchSearchResults( {
q: finalSearch,
sources
}, optsWithAuth )
);
const placesItem = place => ( {
label: place.name,
value: place.uuid
} );
const taxonIcon = taxa => (
<Image source={{ uri: taxa.default_photo.url }} style={imageStyles.pickerIcon} />
);
const userIcon = user => (
<Image source={{ uri: user.icon }} style={imageStyles.circularPickerIcon} />
);
const projectIcon = project => (
<Image source={{ uri: project.icon }} style={imageStyles.pickerIcon} />
);
const taxonItem = taxa => ( {
// 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: taxonIcon( taxa )
} );
const userItem = user => ( {
label: user.login,
value: user.id,
icon: userIcon( user )
} );
const projectItem = project => ( {
label: project.title,
value: project.id,
icon: projectIcon( project )
} );
const displayItems = ( ) => {
if ( finalSearch === "" ) {
return [];
}
if ( !searchResults ) { return []; }
if ( sources === "places" ) {
return searchResults.map( item => placesItem( item ) );
} if ( sources === "taxa" ) {
return searchResults.map( item => taxonItem( item ) );
} if ( sources === "users" ) {
return searchResults.map( item => userItem( item ) );
} if ( sources === "projects" ) {
return searchResults.map( item => projectItem( item ) );
}
return [];
};
// TODO: change to the same style of dropdown as in SettingsRelationships?
// this should be standardized throughout the app
return (
<DropDownPicker
onClose={onClose}
zIndex={zIndex}
zIndexInverse={zIndexInverse}
open={open}
onOpen={onOpen}
value={value}
items={displayItems( )}
setValue={setValue}
searchable
disableLocalSearch
onChangeSearchText={setSearchQuery}
placeholder={t( placeholder )}
style={viewStyles.dropdown}
/>
);
};
export default DropdownPicker;

View File

@@ -1,45 +1,14 @@
// @flow
import ObservationViews from "components/Observations/ObservationViews";
import PlaceholderText from "components/PlaceholderText";
import ViewWithFooter from "components/SharedComponents/ViewWithFooter";
import { ExploreContext } from "providers/contexts";
import type { Node } from "react";
import React, { useContext } from "react";
import { Dimensions } from "react-native";
import React from "react";
import BottomCard from "./BottomCard";
const { height } = Dimensions.get( "screen" );
// make map small enough to show bottom card
const mapHeight = height - 450;
const Explore = ( ): Node => {
const {
exploreList,
loadingExplore,
exploreFilters
} = useContext( ExploreContext );
const taxonId = exploreFilters ? exploreFilters.taxon_id : null;
return (
<ViewWithFooter>
{taxonId !== null && (
<ObservationViews
loading={loadingExplore}
localObservations={{
observationList: exploreList,
unuploadedObsList: [],
allObsToUpload: []
}}
taxonId={taxonId}
testID="Explore.observations"
mapHeight={mapHeight}
/>
)}
<BottomCard />
</ViewWithFooter>
);
};
const Explore = ( ): Node => (
<ViewWithFooter>
<PlaceholderText text="explore placeholder, accessible from left side menu" />
</ViewWithFooter>
);
export default Explore;

View File

@@ -1,434 +0,0 @@
// @flow
import CheckBox from "@react-native-community/checkbox";
import InputField from "components/SharedComponents/InputField";
import ScrollNoFooter from "components/SharedComponents/ScrollNoFooter";
import { Text, View } from "components/styledComponents";
import { t } from "i18next";
import { ExploreContext } from "providers/contexts";
import RadioButtonRN from "radio-buttons-react-native";
import type { Node } from "react";
import React, { useContext, useState } from "react";
import RNPickerSelect from "react-native-picker-select";
import { pickerSelectStyles, viewStyles } from "styles/explore/exploreFilters";
import DropdownPicker from "./DropdownPicker";
import ExploreFooter from "./ExploreFooter";
import ResetFiltersButton from "./ResetFiltersButton";
import TaxonLocationSearch from "./TaxonLocationSearch";
const ExploreFilters = ( ): Node => {
const [project, setProject] = useState( "" );
const [user, setUser] = useState( "" );
const {
exploreFilters,
setExploreFilters,
unappliedFilters,
setUnappliedFilters
} = useContext( ExploreContext );
const setProjectId = getValue => {
setUnappliedFilters( {
...unappliedFilters,
project_id: getValue( )
} );
};
const setUserId = getValue => {
setUnappliedFilters( {
...unappliedFilters,
user_id: getValue( )
} );
};
const sortByRadioButtons = [{
label: t( "Date-added-newest-to-oldest" ),
type: "desc"
}, {
label: t( "Date-added-oldest-to-newest" ),
type: "asc"
}, {
label: t( "Recently-observed" ),
type: "observed_on"
}, {
label: t( "Most-faved" ),
type: "votes"
}];
const reviewedRadioButtons = [{
label: t( "All-observations" ),
type: "all"
}, {
label: t( "Reviewed-only" ),
type: "reviewed"
}, {
label: t( "Unreviewed-only" ),
type: "unreviewed"
}];
const months = [
{ label: t( "Month-January" ), value: 1 },
{ label: t( "Month-February" ), value: 2 },
{ label: t( "Month-March" ), value: 3 },
{ label: t( "Month-April" ), value: 4 },
{ label: t( "Month-May" ), value: 5 },
{ label: t( "Month-June" ), value: 6 },
{ label: t( "Month-July" ), value: 7 },
{ label: t( "Month-August" ), value: 8 },
{ label: t( "Month-September" ), value: 9 },
{ label: t( "Month-October" ), value: 10 },
{ label: t( "Month-November" ), value: 11 },
{ label: t( "Month-December" ), value: 12 }
];
const photoLicenses = [
{ label: t( "All" ), value: "all" },
{ label: "CC-BY", value: "cc-by" },
{ label: "CC-BY-NC", value: "cc-by-nc" },
{ label: "CC-BY-ND", value: "cc-by-nd" },
{ label: "CC-BY-SA", value: "cc-by-sa" },
{ label: "CC-BY-NC-ND", value: "cc-by-nc-nd" },
{ label: "CC-BY-NC-SA", value: "cc-by-nc-sa" },
{ label: "CC0", value: "cc0" }
];
const ranks = [
{ label: t( "Ranks-stateofmatter" ), value: "stateofmatter" },
{ label: t( "Ranks-kingdom" ), value: "kingdom" },
{ label: t( "Ranks-subkingdom" ), value: "subkingdom" },
{ label: t( "Ranks-phylum" ), value: "phylum" },
{ label: t( "Ranks-subphylum" ), value: "subphylum" },
{ label: t( "Ranks-superclass" ), value: "superclass" },
{ label: t( "Ranks-class" ), value: "class" },
{ label: t( "Ranks-subclass" ), value: "subclass" },
{ label: t( "Ranks-infraclass" ), value: "infraclass" },
{ label: t( "Ranks-superorder" ), value: "superorder" },
{ label: t( "Ranks-order" ), value: "order" },
{ label: t( "Ranks-suborder" ), value: "suborder" },
{ label: t( "Ranks-infraorder" ), value: "infraorder" },
{ label: t( "Ranks-subterclass" ), value: "subterclass" },
{ label: t( "Ranks-parvorder" ), value: "parvorder" },
{ label: t( "Ranks-zoosection" ), value: "zoosection" },
{ label: t( "Ranks-zoosubsection" ), value: "zoosubsection" },
{ label: t( "Ranks-superfamily" ), value: "superfamily" },
{ label: t( "Ranks-epifamily" ), value: "epifamily" },
{ label: t( "Ranks-family" ), value: "family" },
{ label: t( "Ranks-subfamily" ), value: "subfamily" },
{ label: t( "Ranks-supertribe" ), value: "supertribe" },
{ label: t( "Ranks-tribe" ), value: "tribe" },
{ label: t( "Ranks-subtribe" ), value: "subtribe" },
{ label: t( "Ranks-genus" ), value: "genus" },
{ label: t( "Ranks-genushybrid" ), value: "genushybrid" },
{ label: t( "Ranks-subgenus" ), value: "subgenus" },
{ label: t( "Ranks-section" ), value: "section" },
{ label: t( "Ranks-subsection" ), value: "subsection" },
{ label: t( "Ranks-complex" ), value: "complex" },
{ label: t( "Ranks-species" ), value: "species" },
{ label: t( "Ranks-hybrid" ), value: "hybrid" },
{ label: t( "Ranks-subspecies" ), value: "subspecies" },
{ label: t( "Ranks-variety" ), value: "variety" },
{ label: t( "Ranks-form" ), value: "form" },
{ label: t( "Ranks-infrahybrid" ), value: "infrahybrid" }
];
const projectId = unappliedFilters ? unappliedFilters.project_id : null;
const userId = unappliedFilters ? unappliedFilters.user_id : null;
const renderQualityGradeCheckbox = qualityGrade => {
const filter = unappliedFilters.quality_grade;
const hasFilter = filter.includes( qualityGrade );
return (
<CheckBox
boxType="square"
disabled={false}
value={hasFilter}
onValueChange={( ) => {
if ( hasFilter ) {
setUnappliedFilters( {
...unappliedFilters,
quality_grade: filter.filter( e => e !== qualityGrade )
} );
} else {
filter.push( qualityGrade );
setUnappliedFilters( {
...unappliedFilters,
quality_grade: filter
} );
}
}}
style={viewStyles.checkbox}
/>
);
};
const renderMediaCheckbox = mediaType => {
const { sounds, photos } = unappliedFilters;
return (
<CheckBox
boxType="square"
disabled={false}
value={mediaType === "photos" ? photos : sounds}
onValueChange={( ) => {
if ( mediaType === "photos" ) {
setUnappliedFilters( {
...unappliedFilters,
photos: !unappliedFilters.photos
} );
} else {
setUnappliedFilters( {
...unappliedFilters,
sounds: !unappliedFilters.sounds
} );
}
}}
style={viewStyles.checkbox}
/>
);
};
const renderStatusCheckbox = status => {
const {
native, captive, introduced, threatened
} = unappliedFilters;
let value;
if ( status === "native" ) {
value = native;
} else if ( status === "captive" ) {
value = captive;
} else if ( status === "introduced" ) {
value = introduced;
} else {
value = threatened;
}
return (
<CheckBox
boxType="square"
disabled={false}
value={value}
onValueChange={( ) => {
setUnappliedFilters( {
...unappliedFilters,
// $FlowFixMe
[status]: !unappliedFilters[status]
} );
}}
style={viewStyles.checkbox}
/>
);
};
const renderRankPicker = rank => (
<RNPickerSelect
onValueChange={itemValue => {
setUnappliedFilters( {
...unappliedFilters,
// $FlowFixMe
[rank]: [itemValue]
} );
}}
items={ranks}
useNativeAndroidPickerStyle={false}
style={pickerSelectStyles}
value={unappliedFilters[rank].length > 0 ? unappliedFilters[rank][0] : null}
/>
);
const renderMonthsPicker = ( ) => {
const firstMonth = unappliedFilters.months[0];
const lastMonth = unappliedFilters.months[unappliedFilters.months.length - 1];
const includesMonth = value => unappliedFilters.months.includes( value );
const fillInMonths = itemValue => {
months.forEach( ( { value } ) => {
if ( value >= firstMonth && value <= itemValue && !includesMonth( value ) ) {
unappliedFilters.months.push( value );
} else if ( value > itemValue && includesMonth( value ) ) {
const index = unappliedFilters.months.indexOf( value );
unappliedFilters.months.splice( index );
}
} );
setUnappliedFilters( { ...unappliedFilters } );
};
return (
<>
<RNPickerSelect
onValueChange={itemValue => {
unappliedFilters.months = [itemValue];
setUnappliedFilters( { ...unappliedFilters } );
}}
items={months}
useNativeAndroidPickerStyle={false}
style={pickerSelectStyles}
value={firstMonth}
/>
<RNPickerSelect
onValueChange={itemValue => fillInMonths( itemValue )}
items={months}
useNativeAndroidPickerStyle={false}
style={pickerSelectStyles}
value={lastMonth}
/>
</>
);
};
return (
<>
<ScrollNoFooter>
<TaxonLocationSearch />
<Text>{t( "Sort-by" )}</Text>
<RadioButtonRN
data={sortByRadioButtons}
initial={1}
boxStyle={viewStyles.radioButtonBox}
selectedBtn={( { type } ) => {
if ( type === "desc" || type === "asc" ) {
setExploreFilters( {
...exploreFilters,
order: type,
order_by: "created_at"
} );
} else {
// votes or observed_on only sort by most recent
setExploreFilters( {
...exploreFilters,
order: "desc",
order_by: type
} );
}
}}
/>
<View style={viewStyles.filtersRow}>
<Text>{t( "Filters" )}</Text>
<ResetFiltersButton />
</View>
<Text>{t( "Quality-Grade" )}</Text>
<View style={viewStyles.checkboxRow}>
{renderQualityGradeCheckbox( "research" )}
<Text>{t( "Research-Grade" )}</Text>
</View>
<View style={viewStyles.checkboxRow}>
{renderQualityGradeCheckbox( "needs_id" )}
<Text>{t( "Needs-ID" )}</Text>
</View>
<View style={viewStyles.checkboxRow}>
{renderQualityGradeCheckbox( "casual" )}
<Text>{t( "Casual" )}</Text>
</View>
<Text>{t( "User" )}</Text>
<Text>{t( "Search-for-a-user" )}</Text>
<DropdownPicker
searchQuery={user}
setSearchQuery={setUser}
setValue={setUserId}
sources="users"
value={userId}
/>
<Text>{t( "Projects" )}</Text>
<Text>{t( "Search-for-a-project" )}</Text>
<DropdownPicker
searchQuery={project}
setSearchQuery={setProject}
setValue={setProjectId}
sources="projects"
value={projectId}
/>
<Text>{t( "Rank" )}</Text>
<Text>{t( "Low" )}</Text>
{renderRankPicker( "lrank" )}
<Text>{t( "High" )}</Text>
{renderRankPicker( "hrank" )}
<Text>{t( "Date" )}</Text>
<Text>{t( "Months" )}</Text>
{renderMonthsPicker( )}
<Text>{t( "Media" )}</Text>
<View style={viewStyles.checkboxRow}>
{renderMediaCheckbox( "photos" )}
<Text>{t( "Has-Photos" )}</Text>
</View>
<View style={viewStyles.checkboxRow}>
{renderMediaCheckbox( "sounds" )}
<Text>{t( "Has-Sounds" )}</Text>
</View>
<Text>{t( "Status" )}</Text>
<View style={viewStyles.checkboxRow}>
{renderStatusCheckbox( "introduced" )}
<Text>{t( "Introduced" )}</Text>
</View>
<View style={viewStyles.checkboxRow}>
{renderStatusCheckbox( "native" )}
<Text>{t( "Native" )}</Text>
</View>
<View style={viewStyles.checkboxRow}>
{renderStatusCheckbox( "threatened" )}
<Text>{t( "Threatened" )}</Text>
</View>
<View style={viewStyles.checkboxRow}>
{renderStatusCheckbox( "captive" )}
<Text>{t( "Captive-Cultivated" )}</Text>
</View>
<Text>{t( "Reviewed" )}</Text>
<RadioButtonRN
data={reviewedRadioButtons}
initial={1}
boxStyle={viewStyles.radioButtonBox}
selectedBtn={( { type } ) => {
if ( type === "all" ) {
delete unappliedFilters.reviewed;
setUnappliedFilters( { ...unappliedFilters } );
} else if ( type === "reviewed" ) {
setUnappliedFilters( {
...unappliedFilters,
reviewed: true
} );
} else {
setUnappliedFilters( {
...unappliedFilters,
reviewed: false
} );
}
}}
/>
<Text>{t( "Photo-Licensing" )}</Text>
<RNPickerSelect
onValueChange={itemValue => {
setUnappliedFilters( {
...unappliedFilters,
photo_license: itemValue === "all" ? [] : [itemValue]
} );
}}
items={photoLicenses}
useNativeAndroidPickerStyle={false}
style={pickerSelectStyles}
value={
unappliedFilters.photo_license.length > 0
? unappliedFilters.photo_license[0]
: "all"
}
/>
<Text>{t( "Description-Tags" )}</Text>
<InputField
handleTextChange={q => {
setUnappliedFilters( {
...unappliedFilters,
q
} );
}}
placeholder={t( "Search-for-description-tags-text" )}
text={unappliedFilters.q}
type="none"
/>
<View style={viewStyles.bottomPadding} />
</ScrollNoFooter>
<ExploreFooter />
</>
);
};
export default ExploreFilters;

View File

@@ -1,40 +0,0 @@
// @flow
import { HeaderBackButton } from "@react-navigation/elements";
import { useNavigation } from "@react-navigation/native";
import Button from "components/SharedComponents/Buttons/Button";
import { t } from "i18next";
import { ExploreContext } from "providers/contexts";
import type { Node } from "react";
import React from "react";
import { View } from "react-native";
import { viewStyles } from "styles/explore/exploreFilters";
const ExploreFooter = ( ): Node => {
const { applyFilters, resetUnappliedFilters } = React.useContext( ExploreContext );
const navigation = useNavigation( );
const applyFiltersAndNavigate = ( ) => {
applyFilters( );
navigation.goBack( );
};
const clearFiltersAndNavigate = ( ) => {
resetUnappliedFilters( );
navigation.goBack( );
};
return (
<View style={viewStyles.footer}>
<HeaderBackButton onPress={clearFiltersAndNavigate} style={viewStyles.element} />
<Button
level="primary"
onPress={applyFiltersAndNavigate}
text={t( "Apply Filters" )}
testID="ExploreFilters.applyFilters"
/>
</View>
);
};
export default ExploreFooter;

View File

@@ -1,52 +0,0 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import Button from "components/SharedComponents/Buttons/Button";
import ViewWithFooter from "components/SharedComponents/ViewWithFooter";
import { Text, View } from "components/styledComponents";
import { t } from "i18next";
import { ExploreContext } from "providers/contexts";
import type { Node } from "react";
import React, { useContext } from "react";
import { textStyles, viewStyles } from "styles/explore/explore";
import FiltersIcon from "./FiltersIcon";
import TaxonLocationSearch from "./TaxonLocationSearch";
const Explore = ( ): Node => {
const {
setLoading,
exploreFilters
} = useContext( ExploreContext );
const navigation = useNavigation( );
const navToExplore = ( ) => {
setLoading( );
navigation.navigate( "Explore" );
};
const taxonId = exploreFilters ? exploreFilters.taxon_id : null;
return (
<ViewWithFooter>
<Text style={textStyles.explanation}>
{t( "Visually-search-iNaturalist-data" )}
</Text>
<FiltersIcon />
<TaxonLocationSearch />
<View style={viewStyles.positionBottom}>
<Button
level="primary"
text={t( "Explore" )}
onPress={navToExplore}
// eslint-disable-next-line react-native/no-inline-styles
style={viewStyles.button}
testID="Explore.fetchObservations"
disabled={!taxonId}
/>
</View>
</ViewWithFooter>
);
};
export default Explore;

View File

@@ -1,23 +0,0 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import * as React from "react";
import { Pressable } from "react-native";
import IconMaterial from "react-native-vector-icons/MaterialIcons";
import { viewStyles } from "styles/observations/messagesIcon";
const FiltersIcon = ( ): React.Node => {
const navigation = useNavigation( );
const navToExploreFilters = ( ) => navigation.navigate( "ExploreFilters" );
return (
<Pressable
onPress={navToExploreFilters}
style={viewStyles.messages}
>
<IconMaterial name="filter-list" size={35} />
</Pressable>
);
};
export default FiltersIcon;

View File

@@ -1,22 +0,0 @@
// @flow
import { Pressable, Text } from "components/styledComponents";
import { t } from "i18next";
import { ExploreContext } from "providers/contexts";
import * as React from "react";
import { viewStyles } from "styles/observations/messagesIcon";
const ResetFiltersButton = ( ): React.Node => {
const { resetFilters } = React.useContext( ExploreContext );
return (
<Pressable
onPress={resetFilters}
style={viewStyles.messages}
>
<Text>{t( "Reset" )}</Text>
</Pressable>
);
};
export default ResetFiltersButton;

View File

@@ -1,90 +0,0 @@
// @flow
import { Text } from "components/styledComponents";
import { t } from "i18next";
import { ExploreContext } from "providers/contexts";
import type { Node } from "react";
import React, { useCallback, useContext, useState } from "react";
import DropdownPicker from "./DropdownPicker";
const TaxonLocationSearch = ( ): Node => {
const {
exploreFilters,
setExploreFilters,
taxon,
setTaxon,
location,
setLocation
} = useContext( ExploreContext );
const [taxonOpen, setTaxonOpen] = useState( false );
const [locationOpen, setLocationOpen] = useState( false );
const onTaxonOpen = useCallback( ( ) => {
setTaxonOpen( true );
setLocationOpen( false );
}, [] );
const onLocationOpen = useCallback( ( ) => {
setLocationOpen( true );
setTaxonOpen( false );
}, [] );
const onClose = useCallback( ( ) => {
setLocationOpen( false );
setTaxonOpen( false );
}, [] );
const setTaxonId = getValue => {
setExploreFilters( {
...exploreFilters,
taxon_id: getValue( )
} );
};
const setPlaceId = getValue => {
setExploreFilters( {
...exploreFilters,
place_id: getValue( )
} );
};
const taxonId = exploreFilters ? exploreFilters.taxon_id : null;
const placeId = exploreFilters ? exploreFilters.place_id : null;
return (
<>
<Text>{t( "Taxon" )}</Text>
<DropdownPicker
zIndex={3000}
zIndexInverse={1000}
searchQuery={taxon}
setSearchQuery={setTaxon}
setValue={setTaxonId}
sources="taxa"
value={taxonId}
placeholder="Search-for-a-taxon"
open={taxonOpen}
onOpen={onTaxonOpen}
onClose={onClose}
/>
<Text>{t( "Location" )}</Text>
<DropdownPicker
zIndex={2000}
zIndexInverse={2000}
searchQuery={location}
setSearchQuery={setLocation}
setValue={setPlaceId}
sources="places"
value={placeId}
placeholder="Search-for-a-location"
open={locationOpen}
onOpen={onLocationOpen}
onClose={onClose}
/>
</>
);
};
export default TaxonLocationSearch;

View File

@@ -1,10 +1,9 @@
// @flow
import { searchObservations } from "api/observations";
import DropdownPicker from "components/Explore/DropdownPicker";
import ViewWithFooter from "components/SharedComponents/ViewWithFooter";
import type { Node } from "react";
import React, { useState } from "react";
import React from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import Observation from "realmModels/Observation";
@@ -16,25 +15,12 @@ import GridView from "./GridView";
const Identify = ( ): Node => {
const [view, setView] = React.useState( "grid" );
const [location, setLocation] = useState( "" );
const [placeId, setPlaceId] = useState( null );
const [taxon, setTaxon] = useState( "" );
const [taxonId, setTaxonId] = useState( null );
const searchParams = {
reviewed: false,
fields: Observation.FIELDS
};
if ( placeId ) {
// $FlowIgnore
searchParams.place_id = placeId;
}
if ( taxonId ) {
// $FlowIgnore
searchParams.taxon_id = taxonId;
}
const {
data: observations,
isLoading
@@ -43,9 +29,6 @@ const Identify = ( ): Node => {
optsWithAuth => searchObservations( searchParams, optsWithAuth )
);
const updatePlaceId = getValue => setPlaceId( getValue( ) );
const updateTaxonId = getValue => setTaxonId( getValue( ) );
const setGridView = ( ) => setView( "grid" );
const setCardView = ( ) => setView( "card" );
@@ -83,20 +66,6 @@ const Identify = ( ): Node => {
<Text>{ t( "Grid-View" ) }</Text>
</Pressable>
</View>
<DropdownPicker
searchQuery={location}
setSearchQuery={setLocation}
setValue={updatePlaceId}
sources="places"
value={placeId}
/>
<DropdownPicker
searchQuery={taxon}
setSearchQuery={setTaxon}
setValue={updateTaxonId}
sources="taxa"
value={taxonId}
/>
{renderView( )}
</ViewWithFooter>
);

View File

@@ -1,11 +1,10 @@
// @flow
import DropdownPicker from "components/Explore/DropdownPicker";
import Map from "components/SharedComponents/Map";
import { format, parseISO } from "date-fns";
import { t } from "i18next";
import type { Node } from "react";
import React, { useState } from "react";
import React from "react";
import { Text, View } from "react-native";
import IconMaterial from "react-native-vector-icons/MaterialIcons";
import { textStyles, viewStyles } from "styles/obsDetails/obsDetails";
@@ -18,17 +17,9 @@ type Props = {
}
const DataTab = ( { observation }: Props ): Node => {
const [project, setProject] = useState( "" );
const [projectId, setProjectId] = useState( null );
const application = observation?.application?.name;
const attribution = observation?.taxon?.default_photo?.attribution;
const selectProjectId = getValue => {
// TODO: add api call for add to project
setProjectId( getValue( ) );
};
const displayTimeObserved = ( ) => {
const timeObservedAt = checkCamelAndSnakeCase( observation, "timeObservedAt" );
if ( timeObservedAt ) {
@@ -77,14 +68,6 @@ const DataTab = ( { observation }: Props ): Node => {
</View>
) }
<Text style={textStyles.dataTabHeader}>{t( "Projects" )}</Text>
{/* TODO: create a custom dropdown that doesn't use FlatList */}
<DropdownPicker
searchQuery={project}
setSearchQuery={setProject}
setValue={selectProjectId}
sources="projects"
value={projectId}
/>
<Text style={textStyles.dataTabHeader}>{t( "Other-Data" )}</Text>
{attribution && <Text style={textStyles.dataTabText}>{attribution}</Text>}
{application && (

View File

@@ -5,10 +5,21 @@ import type { Node } from "react";
import React from "react";
import { ActivityIndicator } from "react-native";
const InfiniteScrollFooter = ( ): Node => (
<View className="h-32 border border-border pt-10">
<ActivityIndicator />
</View>
);
type Props = {
view: string,
isLoading: boolean
}
const InfiniteScrollFooter = ( { view, isLoading }: Props ): Node => {
const className = `${view === "grid" ? "h-64" : "h-32"} border-t border-border py-16`;
if ( isLoading ) {
return (
<View className={className}>
<ActivityIndicator />
</View>
);
}
return <View className={className} />;
};
export default InfiniteScrollFooter;

View File

@@ -35,6 +35,7 @@ const ObsCardStats = ( { item, type, view }: Props ): Node => {
casual: t( "C" )
};
// console.log( item.viewed, "viewed" );
const renderIdRow = ( ) => (
<View className="flex-row items-center mr-3">
<Icon name="shield" color={setIconColor( )} size={14} />

View File

@@ -1,33 +1,47 @@
// @flow
import { fetchObservationUpdates } from "api/observations";
import ObservationViews from "components/Observations/ObservationViews";
import ViewWithFooter from "components/SharedComponents/ViewWithFooter";
import { RealmContext } from "providers/contexts";
import type { Node } from "react";
import React from "react";
import useLocalObservations from "sharedHooks/useLocalObservations";
import useRemoteObservations from "sharedHooks/useRemoteObservations";
import React, { useEffect } from "react";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
const { useRealm } = RealmContext;
const ObsList = ( ): Node => {
const localObservations = useLocalObservations( );
const { observationList } = localObservations;
const {
loading,
syncObservations,
fetchNextObservations
} = useRemoteObservations( );
const realm = useRealm( );
return (
<ViewWithFooter>
<ObservationViews
loading={loading}
localObservations={localObservations}
testID="ObsList.myObservations"
// needs to be refactøred, fetches forever once all observations are fetched
handleEndReached={( ) => fetchNextObservations( observationList.length )}
syncObservations={syncObservations}
/>
</ViewWithFooter>
const updateParams = {
// TODO: viewed = false is a param in the API v2 docs
// but it's currently not returning any results
// so filtering in useEffect instead
observations_by: "owner",
per_page: 100,
fields: "viewed,resource_uuid"
};
// TODO: does this make more sense to put in an App.js component?
const {
data: updates
} = useAuthenticatedQuery(
["fetchObservationUpdates"],
optsWithAuth => fetchObservationUpdates( updateParams, optsWithAuth )
);
useEffect( ( ) => {
if ( !updates ) { return; }
const unviewed = updates.filter( result => result.viewed === false ).map( r => r );
unviewed.forEach( update => {
const existingObs = realm?.objectForPrimaryKey( "Observation", update.resource_uuid );
if ( !existingObs ) { return; }
realm?.write( ( ) => {
existingObs.viewed = update.viewed;
} );
} );
}, [realm, updates] );
return <ObservationViews />;
};
export default ObsList;

View File

@@ -13,13 +13,11 @@ type Props = {
numOfUnuploadedObs: number,
isLoggedIn: ?boolean,
translateY: any,
isExplore: boolean,
syncObservations: Function,
setView: Function
}
const ObsListHeader = ( {
numOfUnuploadedObs, isLoggedIn, translateY, isExplore, syncObservations, setView
numOfUnuploadedObs, isLoggedIn, translateY, setView
}: Props ): Node => {
if ( isLoggedIn === null ) {
return <View className="rounded-bl-3xl rounded-br-3xl bg-primary h-24" />;
@@ -34,9 +32,7 @@ const ObsListHeader = ( {
: <LoggedOutCard numOfUnuploadedObs={numOfUnuploadedObs} />}
</View>
<Toolbar
isExplore={isExplore}
isLoggedIn={isLoggedIn}
syncObservations={syncObservations}
setView={setView}
/>
</Animated.View>

View File

@@ -1,15 +1,19 @@
// @flow
import { useNavigation, useRoute } from "@react-navigation/native";
import { useNavigation } from "@react-navigation/native";
import { searchObservations } from "api/observations";
import BottomSheet from "components/SharedComponents/BottomSheet";
import Map from "components/SharedComponents/Map";
import ViewWithFooter from "components/SharedComponents/ViewWithFooter";
import { View } from "components/styledComponents";
import { RealmContext } from "providers/contexts";
import type { Node } from "react";
import React, { useMemo, useRef, useState } from "react";
import {
ActivityIndicator,
Animated, Dimensions
} from "react-native";
import React, {
useEffect, useMemo, useRef, useState
} from "react";
import { Animated, Dimensions } from "react-native";
import Observation from "realmModels/Observation";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
import useLocalObservations from "sharedHooks/useLocalObservations";
import useLoggedIn from "sharedHooks/useLoggedIn";
import useUploadStatus from "sharedHooks/useUploadStatus";
@@ -22,31 +26,51 @@ import ObsListHeader from "./ObsListHeader";
import UploadProgressBar from "./UploadProgressBar";
import UploadPrompt from "./UploadPrompt";
type Props = {
loading: boolean,
localObservations: Object,
testID: string,
taxonId?: number,
mapHeight?: number,
handleEndReached?: Function,
syncObservations?: Function
}
const { useRealm } = RealmContext;
const ObservationViews = ( {
loading,
localObservations,
testID,
taxonId,
mapHeight,
handleEndReached,
syncObservations
}: Props ): Node => {
const ObservationViews = ( ): Node => {
const localObservations = useLocalObservations( );
const realm = useRealm( );
const [view, setView] = useState( "list" );
const navigation = useNavigation( );
const { name } = useRoute( );
const isLoggedIn = useLoggedIn( );
const { observationList, unuploadedObsList } = localObservations;
const numOfUnuploadedObs = unuploadedObsList?.length;
const currentUser = realm.objects( "User" ).filtered( "signedIn == true" )[0];
const [idBelow, setIdBelow] = useState( null );
const params = {
user_id: currentUser?.id,
per_page: 10,
fields: Observation.FIELDS
};
if ( idBelow ) {
// $FlowIgnore
params.id_below = idBelow;
} else {
// $FlowIgnore
params.page = 1;
}
const {
data: observations,
isLoading
} = useAuthenticatedQuery(
["searchObservations", idBelow],
optsWithAuth => searchObservations( params, optsWithAuth ),
{
keepPreviousData: true
}
);
useEffect( ( ) => {
if ( observations ) {
Observation.updateLocalObservationsFromRemote( realm, observations );
}
}, [realm, observations] );
// eslint-disable-next-line
const [hasScrolled, setHasScrolled] = useState( false );
@@ -112,6 +136,7 @@ const ObservationViews = ( {
const renderItem = ( { item } ) => (
<ObsCard item={item} handlePress={navToObsDetails} />
);
const renderGridItem = ( { item, index } ) => (
<GridItem
item={item}
@@ -121,10 +146,11 @@ const ObservationViews = ( {
);
const renderEmptyState = ( ) => {
if ( name !== "Explore" && isLoggedIn === false ) {
if ( ( isLoggedIn === false )
|| ( !isLoading && observationList.length === 0 ) ) {
return <EmptyList />;
}
return <ActivityIndicator />;
return <View />;
};
const renderBottomSheet = ( ) => {
@@ -159,57 +185,57 @@ const ObservationViews = ( {
const renderFooter = ( ) => {
if ( isLoggedIn === false ) { return <View />; }
return loading
? <InfiniteScrollFooter />
: <View className="pt-16" />;
return (
<InfiniteScrollFooter
view={view}
isLoading={isLoading}
/>
);
};
const isExplore = name === "Explore";
const renderHeader = useMemo( ( ) => (
<ObsListHeader
numOfUnuploadedObs={numOfUnuploadedObs}
isLoggedIn={isLoggedIn}
translateY={translateY}
isExplore={isExplore}
syncObservations={syncObservations}
setView={setView}
/>
), [isExplore, isLoggedIn, translateY, numOfUnuploadedObs, syncObservations] );
), [isLoggedIn, translateY, numOfUnuploadedObs] );
const renderItemSeparator = ( ) => <View className="border border-border" />;
const renderView = ( ) => {
if ( view === "map" ) {
return <Map taxonId={taxonId} mapHeight={mapHeight} />;
const onEndReached = ( ) => {
if ( !isLoading ) {
setIdBelow( observationList[observationList.length - 1].id );
}
return (
<>
<Animated.FlatList
data={observationList}
key={view === "grid" ? 1 : 0}
renderItem={view === "grid" ? renderGridItem : renderItem}
numColumns={view === "grid" ? 2 : 1}
testID={testID}
ListEmptyComponent={renderEmptyState}
onScroll={handleScroll}
onEndReached={handleEndReached}
ListFooterComponent={renderFooter}
ListHeaderComponent={renderHeader}
ItemSeparatorComponent={view !== "grid" && renderItemSeparator}
stickyHeaderIndices={[0]}
bounces={false}
contentContainerStyle={{ minHeight: flatListHeight }}
/>
{numOfUnuploadedObs > 0 && renderBottomSheet( )}
</>
);
};
return (
<View testID="ObservationViews.myObservations">
{renderView( )}
</View>
<ViewWithFooter>
<Animated.FlatList
data={observationList}
key={view === "grid" ? 1 : 0}
contentContainerStyle={{
// add extra height to make lists scrollable when there are less
// items than can fill the screen
minHeight: flatListHeight + 400
}}
testID="ObservationViews.myObservations"
numColumns={view === "grid" ? 2 : 1}
renderItem={view === "grid" ? renderGridItem : renderItem}
ListEmptyComponent={renderEmptyState}
ListHeaderComponent={renderHeader}
ListFooterComponent={renderFooter}
ItemSeparatorComponent={view !== "grid" && renderItemSeparator}
stickyHeaderIndices={[0]}
bounces={false}
initialNumToRender={10}
onScroll={handleScroll}
onEndReached={onEndReached}
onEndReachedThreshold={0.1}
/>
{numOfUnuploadedObs > 0 && renderBottomSheet( )}
</ViewWithFooter>
);
};

View File

@@ -6,21 +6,23 @@ import React from "react";
import IconMaterial from "react-native-vector-icons/MaterialIcons";
type Props = {
isExplore: boolean,
isLoggedIn: ?boolean,
syncObservations: Function,
setView: Function
}
const Toolbar = ( {
isExplore,
isLoggedIn,
syncObservations,
setView
}: Props ): Node => (
<View className="py-5 flex-row justify-between bg-white">
{!isExplore && isLoggedIn ? (
<Pressable onPress={syncObservations} className="mx-3" accessibilityRole="button">
{isLoggedIn ? (
// TODO: syncing observations probably involves uploading, then downloading
// but not entirely sure what this button is supposed to do in what order
<Pressable
onPress={( ) => console.log( "sync observations" )}
className="mx-3"
accessibilityRole="button"
>
<IconMaterial name="sync" size={30} />
</Pressable>
) : (

View File

@@ -4,6 +4,7 @@ import { useNavigation } from "@react-navigation/native";
import { fetchRemoteUser } from "api/users";
import UserIcon from "components/SharedComponents/UserIcon";
import { Pressable, Text, View } from "components/styledComponents";
import { t } from "i18next";
import type { Node } from "react";
import React from "react";
import IconMaterial from "react-native-vector-icons/MaterialIcons";
@@ -41,7 +42,7 @@ const UserCard = ( ): Node => {
<Text className="color-white my-1">{User.userHandle( user )}</Text>
{remoteUser && (
<Text className="color-white my-1">
{`${remoteUser?.observations_count} Observations`}
{t( "X-Observations", { count: remoteUser?.observations_count || 0 } )}
</Text>
)}
</View>

View File

@@ -1,59 +0,0 @@
// @flow
import { RealmContext } from "providers/contexts";
import { useCallback, useEffect, useState } from "react";
import Observation from "realmModels/Observation";
import useApiToken from "sharedHooks/useApiToken";
const { useRealm } = RealmContext;
const useUploadObservations = ( allObsToUpload: Array<Object> ): Object => {
const [cancelUpload, setCancelUpload] = useState( false );
const [currentUploadIndex, setCurrentUploadIndex] = useState( 0 );
const [status, setStatus] = useState( null );
const realm = useRealm( );
const apiToken = useApiToken( );
const handleClosePress = useCallback( ( ) => {
setCancelUpload( true );
setStatus( null );
}, [] );
useEffect( ( ) => {
const upload = async obs => {
if ( !apiToken ) return;
const response = await Observation.uploadObservation( obs, apiToken, realm );
console.log( "shared useUploadObservations, response: ", response );
if ( response.results ) { return; }
if ( response.status !== 200 ) {
const error = JSON.parse( response );
// guard against 500 errors / server downtime errors
if ( error?.url?.includes( "observation_photos" ) ) {
setStatus( "photoFailure" );
} else {
setStatus( "failure" );
}
}
};
if ( currentUploadIndex < allObsToUpload.length - 1 ) {
setCurrentUploadIndex( currentUploadIndex + 1 );
}
if ( !cancelUpload ) {
upload( allObsToUpload[currentUploadIndex] );
}
}, [
allObsToUpload,
apiToken,
cancelUpload,
currentUploadIndex,
realm
] );
return {
handleClosePress,
status
};
};
export default useUploadObservations;

View File

@@ -3,8 +3,6 @@
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import StandardCamera from "components/Camera/StandardCamera";
import Explore from "components/Explore/Explore";
import ExploreFilters from "components/Explore/ExploreFilters";
import ExploreLanding from "components/Explore/ExploreLanding";
import Messages from "components/Messages/Messages";
import ObsDetails from "components/ObsDetails/ObsDetails";
import AddID from "components/ObsEdit/AddID";
@@ -25,7 +23,6 @@ import {
hideScreenTransitionAnimation,
showHeader
} from "navigation/navigationOptions";
import ExploreProvider from "providers/ExploreProvider";
import * as React from "react";
import { PermissionsAndroid } from "react-native";
import { PERMISSIONS } from "react-native-permissions";
@@ -76,106 +73,87 @@ const photoGalleryHeaderTitle = ( ) => <PhotoAlbumPicker />;
const MainStackNavigation = ( ): React.Node => (
<Mortal>
<ExploreProvider>
<Stack.Navigator screenOptions={showHeader}>
<Stack.Screen
name="ObsList"
component={ObsList}
options={{
...hideScreenTransitionAnimation,
...hideHeader
}}
/>
<Stack.Screen
name="StandardCamera"
component={StandardCameraWithPermission}
options={hideHeader}
/>
<Stack.Screen
name="PhotoGallery"
component={PhotoGalleryWithPermission}
options={{
headerTitle: photoGalleryHeaderTitle
}}
/>
<Stack.Screen
name="GroupPhotos"
component={GroupPhotos}
options={{
title: t( "Group-Photos" )
}}
/>
<Stack.Screen
name="SoundRecorder"
component={SoundRecorderWithPermission}
options={{
title: t( "Record-new-sound" )
}}
/>
<Stack.Screen
name="ObsEdit"
component={ObsEditWithPermission}
options={blankHeaderTitle}
/>
<Stack.Screen
name="AddID"
component={AddID}
options={{
title: t( "Add-an-ID" )
}}
/>
<Stack.Screen
name="ObsDetails"
component={ObsDetails}
options={{
headerTitle: t( "Observation" )
}}
/>
<Stack.Screen
name="TaxonDetails"
component={TaxonDetails}
options={blankHeaderTitle}
/>
<Stack.Screen
name="UserProfile"
component={UserProfile}
options={blankHeaderTitle}
/>
<Stack.Screen
name="Messages"
component={Messages}
options={{
...showHeader,
...hideScreenTransitionAnimation
}}
/>
<Stack.Screen
name="ExploreLanding"
component={ExploreLanding}
options={{
...showHeader,
...hideScreenTransitionAnimation,
headerTitle: t( "Explore" )
}}
/>
<Stack.Screen
name="Explore"
component={Explore}
options={{
...hideHeader,
...hideScreenTransitionAnimation
}}
/>
<Stack.Screen
name="ExploreFilters"
component={ExploreFilters}
options={{
...hideHeader,
...hideScreenTransitionAnimation
}}
/>
</Stack.Navigator>
</ExploreProvider>
<Stack.Navigator screenOptions={showHeader}>
<Stack.Screen
name="ObsList"
component={ObsList}
options={{
...hideScreenTransitionAnimation,
...hideHeader
}}
/>
<Stack.Screen
name="StandardCamera"
component={StandardCameraWithPermission}
options={hideHeader}
/>
<Stack.Screen
name="PhotoGallery"
component={PhotoGalleryWithPermission}
options={{
headerTitle: photoGalleryHeaderTitle
}}
/>
<Stack.Screen
name="GroupPhotos"
component={GroupPhotos}
options={{
title: t( "Group-Photos" )
}}
/>
<Stack.Screen
name="SoundRecorder"
component={SoundRecorderWithPermission}
options={{
title: t( "Record-new-sound" )
}}
/>
<Stack.Screen
name="ObsEdit"
component={ObsEditWithPermission}
options={blankHeaderTitle}
/>
<Stack.Screen
name="AddID"
component={AddID}
options={{
title: t( "Add-an-ID" )
}}
/>
<Stack.Screen
name="ObsDetails"
component={ObsDetails}
options={{
headerTitle: t( "Observation" )
}}
/>
<Stack.Screen
name="TaxonDetails"
component={TaxonDetails}
options={blankHeaderTitle}
/>
<Stack.Screen
name="UserProfile"
component={UserProfile}
options={blankHeaderTitle}
/>
<Stack.Screen
name="Messages"
component={Messages}
options={{
...showHeader,
...hideScreenTransitionAnimation
}}
/>
<Stack.Screen
name="Explore"
component={Explore}
options={{
...hideHeader,
...hideScreenTransitionAnimation
}}
/>
</Stack.Navigator>
</Mortal>
);

View File

@@ -1,112 +0,0 @@
// @flow
import { searchObservations } from "api/observations";
import type { Node } from "react";
import React, { useMemo, useState } from "react";
import Observation from "realmModels/Observation";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
import { ExploreContext } from "./contexts";
type Props = {
children: any
}
const initialOptions = {
order: "desc",
order_by: "created_at",
taxon_id: null,
place_id: null
};
const initialFilters = {
captive: false,
hrank: [],
introduced: false,
lrank: [],
months: [],
native: false,
photo_license: [],
photos: true,
project_id: null,
// start by showing verifiable observations
quality_grade: ["needs_id", "research"],
sounds: false,
threatened: false,
user_id: null
};
const ExploreProvider = ( { children }: Props ): Node => {
const [exploreFilters, setExploreFilters] = useState( {
...initialOptions,
...initialFilters
} );
const [unappliedFilters, setUnappliedFilters] = useState( {
...initialFilters
} );
const [taxon, setTaxon] = useState( "" );
const [location, setLocation] = useState( "" );
// create filters object excluding keys with null values
const filters = Object.fromEntries(
Object.entries( exploreFilters ).filter( ( [_, v] ) => v != null )
);
const searchParams = {
...filters,
fields: Observation.FIELDS
};
const {
data: exploreList,
isLoading: loadingExplore
} = useAuthenticatedQuery(
["searchObservations"],
optsWithAuth => searchObservations( searchParams, optsWithAuth )
);
const resetUnappliedFilters = ( ) => setUnappliedFilters( {
...initialFilters
} );
const exploreValue = useMemo( ( ) => {
const resetFilters = ( ) => setExploreFilters( {
...exploreFilters,
...initialFilters
} );
const applyFilters = ( ) => {
const applied = Object.assign( exploreFilters, unappliedFilters );
setExploreFilters( applied );
};
return {
applyFilters,
exploreFilters,
exploreList,
loadingExplore,
location,
resetFilters,
resetUnappliedFilters,
setExploreFilters,
setLocation,
setTaxon,
setUnappliedFilters,
taxon,
unappliedFilters
};
}, [
exploreFilters,
exploreList,
loadingExplore,
location,
taxon,
unappliedFilters
] );
return (
<ExploreContext.Provider value={exploreValue}>
{children}
</ExploreContext.Provider>
);
};
export default ExploreProvider;

View File

@@ -223,53 +223,6 @@ class Observation extends Realm.Object {
return { uri: mediumUri };
}
static fetchObservationUpdates = async ( realm, apiToken ) => {
if ( !apiToken ) { return null; }
const params = {
observations_by: "owner",
per_page: 200,
fields: "viewed,resource_uuid"
};
const options = { api_token: apiToken };
try {
const { results } = await inatjs.observations.updates( params, options );
const unviewed = results.filter( result => result.viewed === false ).map( r => r );
unviewed.forEach( update => {
const existingObs = realm?.objectForPrimaryKey( "Observation", update.resource_uuid );
if ( !existingObs ) { return; }
realm?.write( ( ) => {
existingObs.viewed = update.viewed;
} );
} );
return unviewed;
} catch ( e ) {
console.log( "Couldn't fetch observation updates:", JSON.stringify( e ) );
return null;
}
}
static fetchRemoteObservations = async ( page, realm ) => {
const currentUser = realm.objects( "User" ).filtered( "signedIn == true" )[0];
if ( !currentUser ) { return null; }
const params = {
user_id: currentUser.id,
page,
per_page: 6,
fields: Observation.FIELDS
};
try {
const { results } = await inatjs.observations.search( params );
return results;
} catch ( e ) {
console.log( "Couldn't fetch observations:", JSON.stringify( e.response ) );
return null;
}
}
static filterUnsyncedObservations = realm => {
const unsyncedFilter = "_synced_at == null || _synced_at <= _updated_at";
const photosUnsyncedFilter = "ANY observationPhotos._synced_at == null";

View File

@@ -1,68 +0,0 @@
// @flow
import { RealmContext } from "providers/contexts";
import {
useCallback, useEffect, useState
} from "react";
import Observation from "realmModels/Observation";
import useApiToken from "sharedHooks/useApiToken";
const { useRealm } = RealmContext;
const useRemoteObservations = ( ): Object => {
const [loading, setLoading] = useState( false );
const [page, setPage] = useState( 1 );
const [fetchFromServer, setFetchFromServer] = useState( true );
const apiToken = useApiToken( );
const realm = useRealm( );
const syncObservations = useCallback( ( ) => {
setFetchFromServer( true );
}, [] );
const fetchNextObservations = useCallback( numOfObs => {
const nextPageToFetch = numOfObs > 0
? Math.ceil( numOfObs / 5 )
: 1;
setPage( nextPageToFetch );
setFetchFromServer( true );
}, [] );
useEffect( ( ) => {
let isCurrent = true;
const fetchObservations = async ( ) => {
if ( !isCurrent ) return;
if ( !apiToken ) return;
setLoading( true );
// update local observations with unviewed comment or id statuses
await Observation.fetchObservationUpdates( realm, apiToken );
// fetch remote observations
const results = await Observation.fetchRemoteObservations( page, realm );
if ( results ) {
// update realm with new or modified remote observations
Observation.updateLocalObservationsFromRemote( realm, results );
}
// if ( !isCurrent ) { return; }
setLoading( false );
};
if ( fetchFromServer && apiToken ) {
fetchObservations( );
setFetchFromServer( false );
}
return ( ) => {
isCurrent = false;
};
}, [apiToken, page, fetchFromServer, realm] );
return {
loading,
syncObservations,
fetchNextObservations
};
};
export default useRemoteObservations;

View File

@@ -1,64 +0,0 @@
// @flow
import { StyleSheet } from "react-native";
import type {
ImageStyleProp,
TextStyleProp,
ViewStyleProp
} from "react-native/Libraries/StyleSheet/StyleSheet";
import colors from "styles/tailwindColors";
const viewStyles: { [string]: ViewStyleProp } = StyleSheet.create( {
dropdown: {
backgroundColor: colors.white,
borderColor: colors.gray,
borderRadius: 40,
borderWidth: 0.5,
height: 37,
paddingLeft: 15,
marginVertical: 5
},
positionBottom: {
bottom: 140,
width: "100%",
position: "absolute"
},
bottomCard: {
backgroundColor: colors.white,
borderTopRightRadius: 30,
borderTopLeftRadius: 30,
borderColor: colors.gray,
borderWidth: 1,
paddingTop: 20,
paddingBottom: 70,
paddingHorizontal: 20
},
button: {
marginHorizontal: 120
}
} );
const textStyles: { [string]: TextStyleProp } = StyleSheet.create( {
explanation: {
color: colors.gray,
margin: 10
}
} );
const imageStyles: { [string]: ImageStyleProp } = StyleSheet.create( {
circularPickerIcon: {
width: 25,
height: 25,
borderRadius: 50
},
pickerIcon: {
width: 25,
height: 25
}
} );
export {
imageStyles,
textStyles,
viewStyles
};

View File

@@ -1,67 +0,0 @@
// @flow strict-local
import { StyleSheet } from "react-native";
import type { TextStyleProp, ViewStyleProp } from "react-native/Libraries/StyleSheet/StyleSheet";
import colors from "styles/tailwindColors";
const pickerSelectStyles: { [string]: TextStyleProp } = StyleSheet.create( {
inputIOS: {
width: 250,
fontSize: 16,
paddingVertical: 12,
paddingHorizontal: 10,
borderWidth: 1,
borderColor: "gray",
borderRadius: 40,
color: "black",
paddingRight: 30 // to ensure the text is never behind the icon
},
inputAndroid: {
width: 250,
fontSize: 16,
paddingHorizontal: 10,
paddingVertical: 8,
borderWidth: 0.5,
borderColor: "purple",
borderRadius: 8,
color: "black",
paddingRight: 30 // to ensure the text is never behind the icon
}
} );
const checkboxWidth = 18;
const viewStyles: { [string]: ViewStyleProp } = StyleSheet.create( {
checkbox: {
width: checkboxWidth,
height: checkboxWidth,
padding: 10
},
checkboxRow: {
flexDirection: "row",
flexWrap: "nowrap"
},
filtersRow: {
flexDirection: "row",
flexWrap: "nowrap",
justifyContent: "space-between",
backgroundColor: colors.lightGray,
paddingVertical: 20
},
radioButtonBox: {
borderWidth: 0
},
bottomPadding: {
padding: 140
},
footer: {
height: 100,
flexDirection: "row",
flexWrap: "nowrap",
backgroundColor: colors.white
}
} );
export {
pickerSelectStyles,
viewStyles
};

View File

@@ -0,0 +1,6 @@
import { define } from "factoria";
export default define( "RemoteUpdate", faker => ( {
viewed: false,
resource_uuid: faker.datatype.uuid( )
} ) );

View File

@@ -20,16 +20,10 @@ jest.mock( "@react-navigation/native", ( ) => {
const actualNav = jest.requireActual( "@react-navigation/native" );
return {
...actualNav,
useRoute: ( ) => ( {
params: {
name: ""
}
} )
useRoute: ( ) => ( { } )
};
} );
jest.mock( "sharedHooks/useApiToken" );
jest.mock( "sharedHooks/useLoggedIn", ( ) => ( {
__esModule: true,
default: ( ) => true
@@ -74,8 +68,12 @@ test.todo( "only makes one concurrent request for observations at a time" );
test( "should not have accessibility errors", async ( ) => {
const observations = [factory( "RemoteObservation" )];
const updates = [factory( "RemoteUpdate" )];
inatjs.observations.updates.mockResolvedValue( makeResponse( updates ) );
inatjs.observations.search.mockResolvedValue( makeResponse( observations ) );
const { getByTestId } = await waitFor( ( ) => renderObsList( ) );
const obsList = getByTestId( "ObservationViews.myObservations" );
expect( obsList ).toBeAccessible( );
const { getByTestId } = renderObsList( );
await waitFor( ( ) => {
const obsList = getByTestId( "ObservationViews.myObservations" );
expect( obsList ).toBeAccessible( );
} );
} );

View File

@@ -1,151 +0,0 @@
import { NavigationContainer } from "@react-navigation/native";
import {
QueryClient,
QueryClientProvider
} from "@tanstack/react-query";
import { fireEvent, render } from "@testing-library/react-native";
import Explore from "components/Explore/Explore";
import { ExploreContext } from "providers/contexts";
import ExploreProvider from "providers/ExploreProvider";
import React from "react";
import factory from "../../../factory";
const mockLatLng = {
latitude: 37.77,
longitude: -122.42
};
const mockUser = factory( "LocalUser" );
jest.mock( "sharedHooks/useCurrentUser", ( ) => ( {
__esModule: true,
default: ( ) => mockUser
} ) );
jest.mock( "../../../../src/sharedHooks/useLoggedIn", ( ) => ( {
__esModule: true,
default: ( ) => true
} ) );
// Mock the hooks we use on Map since we're not trying to test them here
jest.mock( "../../../../src/sharedHooks/useUserLocation", ( ) => ( {
__esModule: true,
default: ( ) => mockLatLng
} ) );
// Some of the search inputs seem to query the API for some defaults, so this
// tries to make sure they get nothing. It does so for all uses of
// useAuthenticatedQuery, so watch this for unexpected behavior (but a unit
// test really should not be making any network requests)
jest.mock( "sharedHooks/useAuthenticatedQuery", ( ) => ( {
__esModule: true,
default: ( ) => ( { data: null } )
} ) );
jest.mock( "../../../../src/providers/ExploreProvider" );
// Mock ExploreProvider so it provides a specific array of observations
// without any current observation or ability to update or fetch
// observations
const mockExploreProviderWithObservations = observations => (
ExploreProvider.mockImplementation( ( { children } ) => (
<ExploreContext.Provider
// eslint-disable-next-line react/jsx-no-constructed-context-values
value={{
exploreList: observations,
setExploreList: ( ) => {},
setLoading: ( ) => {},
exploreFilters: {},
setExploreFilters: ( ) => {},
resetFilters: ( ) => {}
}}
>
{children}
</ExploreContext.Provider>
) )
);
jest.mock( "@react-navigation/native", ( ) => {
const actualNav = jest.requireActual( "@react-navigation/native" );
return {
...actualNav,
useRoute: ( ) => ( {
name: "Explore"
} )
};
} );
const queryClient = new QueryClient( );
const renderExplore = ( ) => render(
<QueryClientProvider client={queryClient}>
<NavigationContainer>
<ExploreProvider>
<Explore />
</ExploreProvider>
</NavigationContainer>
</QueryClientProvider>
);
// the next three tests are duplicates from ObsList.test.js, with Explore data
// instead of ObsList data
it( "renders an observation", ( ) => {
const observations = [
factory( "LocalObservation" ),
factory( "LocalObservation" )
];
// Mock the provided observations so we're just using our test data
mockExploreProviderWithObservations( observations );
const { getByTestId } = renderExplore( );
const obs = observations[0];
const list = getByTestId( "Explore.observations" );
// Test that there isn't other data lingering
expect( list.props.data.length ).toEqual( observations.length );
// Test that a card got rendered for the our test obs
const card = getByTestId( `ObsList.obsCard.${obs.uuid}` );
expect( card ).toBeTruthy( );
} );
it( "renders multiple observations", ( ) => {
const observations = [
factory( "LocalObservation" ),
factory( "LocalObservation" )
];
mockExploreProviderWithObservations( observations );
const { getByTestId } = renderExplore( );
observations.forEach( obs => {
expect( getByTestId( `ObsList.obsCard.${obs.uuid}` ) ).toBeTruthy( );
} );
} );
it( "renders grid view on button press", ( ) => {
const observations = [
factory( "LocalObservation" )
];
mockExploreProviderWithObservations( observations );
const { getByTestId } = renderExplore( );
const button = getByTestId( "ObsList.toggleGridView" );
fireEvent.press( button );
observations.forEach( obs => {
expect( getByTestId( `ObsList.gridItem.${obs.uuid}` ) ).toBeTruthy( );
} );
} );
test.todo( "renders map view on button press" );
// const observations = [
// factory( "LocalObservation" )
// ];
// mockExploreProviderWithObservations( observations );
// const { getByTestId } = renderExplore( );
// const button = getByTestId( "Explore.toggleMapView" );
// fireEvent.press( button );
// expect( getByTestId( "MapView" ) ).toBeTruthy( );
// } );
// TODO: is there a way to test the dropdown pickers? maybe this will be easier
// when we write our own custom dropdown picker with search

View File

@@ -1,4 +1,8 @@
import { NavigationContainer } from "@react-navigation/native";
import {
QueryClient,
QueryClientProvider
} from "@tanstack/react-query";
import { fireEvent, render, within } from "@testing-library/react-native";
import ObsList from "components/Observations/ObsList";
import React from "react";
@@ -18,18 +22,18 @@ const mockObservations = [
// Mock the hooks we use on ObsList since we're not trying to test them here
jest.mock( "../../../../src/sharedHooks/useCurrentUser", ( ) => ( {
jest.mock( "sharedHooks/useCurrentUser", ( ) => ( {
__esModule: true,
default: ( ) => true
} ) );
jest.mock( "../../../../src/sharedHooks/useLoggedIn", ( ) => ( {
jest.mock( "sharedHooks/useLoggedIn", ( ) => ( {
__esModule: true,
default: ( ) => true
} ) );
jest.mock(
"../../../../src/sharedHooks/useLocalObservations",
"sharedHooks/useLocalObservations",
( ) => ( {
__esModule: true,
default: ( ) => ( {
@@ -38,13 +42,6 @@ jest.mock(
} )
);
jest.mock( "../../../../src/sharedHooks/useRemoteObservations", ( ) => ( {
__esModule: true,
default: ( ) => ( {
loading: false
} )
} ) );
jest.mock( "@react-navigation/native", ( ) => {
const actualNav = jest.requireActual( "@react-navigation/native" );
return {
@@ -57,21 +54,20 @@ jest.mock( "@react-navigation/native", ( ) => {
};
} );
jest.mock( "../../../../src/sharedHooks/useLoggedIn", ( ) => ( {
default: ( ) => false,
__esModule: true
} ) );
const queryClient = new QueryClient( );
const renderObsList = ( ) => render(
<NavigationContainer>
<ObsList />
</NavigationContainer>
<QueryClientProvider client={queryClient}>
<NavigationContainer>
<ObsList />
</NavigationContainer>
</QueryClientProvider>
);
it( "renders an observation", ( ) => {
const { getByTestId } = renderObsList( );
const obs = mockObservations[0];
const list = getByTestId( "ObsList.myObservations" );
const list = getByTestId( "ObservationViews.myObservations" );
// Test that there isn't other data lingering
expect( list.props.data.length ).toEqual( mockObservations.length );