mirror of
https://github.com/inaturalist/iNaturalistReactNative.git
synced 2026-05-06 06:35:57 -04:00
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:
committed by
GitHub
parent
fb4bf79878
commit
8a92fca2e8
2
package-lock.json
generated
2
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
6
tests/factories/RemoteUpdate.js
Normal file
6
tests/factories/RemoteUpdate.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { define } from "factoria";
|
||||
|
||||
export default define( "RemoteUpdate", faker => ( {
|
||||
viewed: false,
|
||||
resource_uuid: faker.datatype.uuid( )
|
||||
} ) );
|
||||
@@ -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( );
|
||||
} );
|
||||
} );
|
||||
|
||||
@@ -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
|
||||
@@ -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 );
|
||||
|
||||
Reference in New Issue
Block a user