Merge pull request #3585 from inaturalist/mob-1329-search-context-storage-observation-fetching

MOB-1329 ExploreV2 state and initial obs fetching
This commit is contained in:
Seth Peterson
2026-05-13 10:59:28 -05:00
committed by GitHub
11 changed files with 891 additions and 1 deletions

View File

@@ -0,0 +1,64 @@
import ExploreStackNavigator
from "navigation/StackNavigators/ExploreStackNavigator";
import {
defaultExploreV2Location,
EXPLORE_V2_ACTION,
EXPLORE_V2_PLACE_MODE,
ExploreV2Provider,
useExploreV2,
} from "providers/ExploreV2Context";
import React, { useEffect, useEffectEvent } from "react";
import useLocationPermission from "sharedHooks/useLocationPermission";
const ExploreV2WithProvider = ( ) => {
const { state, dispatch } = useExploreV2( );
const {
hasPermissions,
} = useLocationPermission( );
// useEffectEvent is a new pattern for us, which we are adding only to new code for the moment
// https://github.com/inaturalist/iNaturalistReactNative/pull/3585#discussion_r3220223241
const onPermissionsGained = useEffectEvent( async ( ) => {
if ( state.location.placeMode !== EXPLORE_V2_PLACE_MODE.UNINITIALIZED ) return;
const next = await defaultExploreV2Location( );
if ( next.placeMode === EXPLORE_V2_PLACE_MODE.NEARBY ) {
dispatch( {
type: EXPLORE_V2_ACTION.SET_LOCATION_NEARBY,
lat: next.lat,
lng: next.lng,
radius: next.radius,
} );
} else {
dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } );
}
} );
// default to "Worldwide" when location is denied
// hasPermissions === false always means permission has been denied or blocked
const onPermissionsDenied = useEffectEvent( ( ) => {
if ( state.location.placeMode === EXPLORE_V2_PLACE_MODE.UNINITIALIZED ) {
dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } );
}
} );
// handle location permission changes on Explore
useEffect( ( ) => {
if ( hasPermissions === true ) {
onPermissionsGained( );
} else if ( hasPermissions === false ) {
onPermissionsDenied( );
}
}, [hasPermissions] );
return (
<ExploreStackNavigator />
);
};
const ExploreV2Container = ( ) => (
<ExploreV2Provider>
<ExploreV2WithProvider />
</ExploreV2Provider>
);
export default ExploreV2Container;

View File

@@ -0,0 +1,81 @@
import type { ExploreV2State } from "providers/ExploreV2Context";
import {
EXPLORE_V2_PLACE_MODE,
EXPLORE_V2_SORT,
} from "providers/ExploreV2Context";
const PER_PAGE = 20;
export interface ExploreV2QueryParams {
per_page: number;
order_by: "created_at" | "observed_on" | "votes";
order: "asc" | "desc";
taxon_id?: number;
user_id?: number;
project_id?: number;
lat?: number;
lng?: number;
radius?: number;
place_id?: number;
verifiable?: boolean;
}
const sortToOrder: Record<
EXPLORE_V2_SORT,
{ order_by: "created_at" | "observed_on" | "votes"; order: "asc" | "desc" }
> = {
[EXPLORE_V2_SORT.DATE_UPLOADED_NEWEST]: { order_by: "created_at", order: "desc" },
[EXPLORE_V2_SORT.DATE_UPLOADED_OLDEST]: { order_by: "created_at", order: "asc" },
[EXPLORE_V2_SORT.DATE_OBSERVED_NEWEST]: { order_by: "observed_on", order: "desc" },
[EXPLORE_V2_SORT.DATE_OBSERVED_OLDEST]: { order_by: "observed_on", order: "asc" },
[EXPLORE_V2_SORT.MOST_FAVED]: { order_by: "votes", order: "desc" },
};
const buildExploreV2QueryParams = (
state: ExploreV2State,
): ExploreV2QueryParams => {
const params: ExploreV2QueryParams = {
per_page: PER_PAGE,
verifiable: true,
...sortToOrder[state.sortBy],
};
// this might warrant moving into a selector function at some point
switch ( state.subject?.type ) {
case "taxon":
params.taxon_id = state.subject.taxon.id;
break;
case "user":
params.user_id = state.subject.user.id;
break;
case "project":
params.project_id = state.subject.project.id;
break;
default:
break;
}
const { location } = state;
switch ( location.placeMode ) {
case EXPLORE_V2_PLACE_MODE.NEARBY:
params.lat = location.lat;
params.lng = location.lng;
params.radius = location.radius;
break;
case EXPLORE_V2_PLACE_MODE.PLACE:
params.place_id = location.place.id;
break;
case EXPLORE_V2_PLACE_MODE.WORLDWIDE:
case EXPLORE_V2_PLACE_MODE.UNINITIALIZED:
break;
default: {
// Exhaustiveness check: ts fails if a new placeMode is added without a case.
const _exhaustive: never = location;
return _exhaustive;
}
}
return params;
};
export default buildExploreV2QueryParams;

View File

@@ -0,0 +1,14 @@
import { Body2, ViewWrapper } from "components/SharedComponents";
import { View } from "components/styledComponents";
import React from "react";
const AdvancedSearch = ( ) => (
<ViewWrapper testID="AdvancedSearch">
<View className="flex-1 items-center justify-center p-4">
{/* eslint-disable-next-line i18next/no-literal-string */}
<Body2>TODO: Advanced Search MOB-1346</Body2>
</View>
</ViewWrapper>
);
export default AdvancedSearch;

View File

@@ -0,0 +1,55 @@
import { useNetInfo } from "@react-native-community/netinfo";
import buildExploreV2QueryParams
from "components/Explore/ExploreV2/buildQueryParams";
import useInfiniteExploreScroll
from "components/Explore/hooks/useInfiniteExploreScroll";
import ObservationsFlashList from "components/ObservationsFlashList/ObservationsFlashList";
import {
Body2,
ViewWrapper,
} from "components/SharedComponents";
import { View } from "components/styledComponents";
import { EXPLORE_V2_PLACE_MODE, useExploreV2 } from "providers/ExploreV2Context";
import React from "react";
const ExploreResults = ( ) => {
const { state } = useExploreV2( );
const { isConnected } = useNetInfo( );
const queryParams = buildExploreV2QueryParams( state );
const canFetch = state.location.placeMode !== EXPLORE_V2_PLACE_MODE.UNINITIALIZED;
const {
fetchNextPage,
isFetchingNextPage,
handlePullToRefresh,
observations,
totalResults,
} = useInfiniteExploreScroll( { params: queryParams, enabled: canFetch } );
return (
<ViewWrapper testID="ExploreResults" wrapperClassName="overflow-hidden">
<View className="flex-1 overflow-hidden">
{/* eslint-disable-next-line i18next/no-literal-string */}
<Body2>TODO: Header MOB-1327</Body2>
<ObservationsFlashList
data={observations}
dataCanBeFetched={canFetch}
explore
handlePullToRefresh={handlePullToRefresh}
hideLoadingWheel={!isFetchingNextPage}
isFetchingNextPage={isFetchingNextPage}
isConnected={isConnected}
layout="list"
obsListKey="ExploreV2Observations"
onEndReached={fetchNextPage}
showNoResults={!canFetch || totalResults === 0}
testID="ExploreV2ObservationsList"
/>
</View>
</ViewWrapper>
);
};
export default ExploreResults;

View File

@@ -0,0 +1,14 @@
import { Body2, ViewWrapper } from "components/SharedComponents";
import { View } from "components/styledComponents";
import React from "react";
const UniversalSearch = ( ) => (
<ViewWrapper testID="UniversalSearch">
<View className="flex-1 items-center justify-center p-4">
{/* eslint-disable-next-line i18next/no-literal-string */}
<Body2>TODO: Universal Search MOB-1338</Body2>
</View>
</ViewWrapper>
);
export default UniversalSearch;

View File

@@ -0,0 +1,36 @@
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import AdvancedSearch
from "components/Explore/ExploreV2/screens/AdvancedSearch";
import ExploreResults
from "components/Explore/ExploreV2/screens/ExploreResults";
import UniversalSearch
from "components/Explore/ExploreV2/screens/UniversalSearch";
import { hideHeader } from "navigation/navigationOptions";
import type { ExploreStackParamList } from "navigation/types";
import React from "react";
// When navigating out of this stack, useNavigation should be typed like:
// useNavigation<ExploreStackScreenProps<"ExploreResults">["navigation"]>( );
const Stack = createNativeStackNavigator<ExploreStackParamList>( );
const ExploreStackNavigator = ( ) => (
<Stack.Navigator initialRouteName="ExploreResults">
<Stack.Screen
name="ExploreResults"
component={ExploreResults}
options={hideHeader}
/>
<Stack.Screen
name="UniversalSearch"
component={UniversalSearch}
options={hideHeader}
/>
<Stack.Screen
name="AdvancedSearch"
component={AdvancedSearch}
options={hideHeader}
/>
</Stack.Navigator>
);
export default ExploreStackNavigator;

View File

@@ -9,6 +9,7 @@ import Donate from "components/Donate/Donate";
import ExploreContainer from "components/Explore/ExploreContainer";
import ExploreFiltersContainer from "components/Explore/ExploreFiltersContainer";
import ExploreSearchContainer from "components/Explore/ExploreSearchContainer";
import ExploreV2Container from "components/Explore/ExploreV2/ExploreV2Container";
import RootExploreContainer from "components/Explore/RootExploreContainer";
import Help from "components/Help/Help";
import Menu from "components/Menu/Menu";
@@ -45,6 +46,8 @@ import React from "react";
import {
useLayoutPrefs,
} from "sharedHooks";
import useFeatureFlag from "sharedHooks/useFeatureFlag";
import { FeatureFlag } from "stores/createFeatureFlagSlice";
import colors from "styles/tailwindColors";
import SharedStackScreens from "./SharedStackScreens";
@@ -170,6 +173,7 @@ const TabStackNavigator = ( { route }: BottomTabProps ) => {
const {
isDefaultMode,
} = useLayoutPrefs( );
const exploreV2Enabled = useFeatureFlag( FeatureFlag.ExploreV2Enabled );
return (
<Stack.Navigator
initialRouteName={initialRouteName}
@@ -197,7 +201,9 @@ const TabStackNavigator = ( { route }: BottomTabProps ) => {
/>
<Stack.Screen
name={SCREEN_NAME_ROOT_EXPLORE}
component={RootExploreContainer}
component={exploreV2Enabled
? ExploreV2Container
: RootExploreContainer}
options={{
...preventSwipeToGoBack,
animation: "none",

View File

@@ -186,6 +186,15 @@ export type OnboardingStackParamList = {
Onboarding: undefined;
};
// Screens hosted by ExploreStackNavigator (ExploreV2)
// The type containing the mapping must be a type alias. It cannot be an interface.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type ExploreStackParamList = {
ExploreResults: undefined;
UniversalSearch: undefined;
AdvancedSearch: undefined;
};
// Tab-only routes (not from SharedStackScreens). Intersected with SharedStackParamList
// so TabStackParamList matches TabStackNavigator + SharedStackScreens.
// Note from the documentation:
@@ -384,6 +393,14 @@ export type TabStackScreenProps<T extends keyof TabStackParamList> =
BottomTabProps
>;
// ExploreStackNavigator is nested inside RootExplore. This composite type
// acknowledges ExploreV2 screens access to outer-stack routes
export type ExploreStackScreenProps<T extends keyof ExploreStackParamList> =
CompositeScreenProps<
NativeStackScreenProps<ExploreStackParamList, T>,
TabStackScreenProps<"RootExplore">
>;
export type NoBottomTabStackScreenProps<T extends keyof NoBottomTabStackParamList> =
CompositeScreenProps<
NativeStackScreenProps<NoBottomTabStackParamList, T>,

View File

@@ -0,0 +1,210 @@
import * as React from "react";
// Please don't change this to an aliased path or the e2e mock will not get
// used in our e2e tests on Github Actions
import fetchCoarseUserLocation from "../sharedHelpers/fetchCoarseUserLocation";
export enum EXPLORE_V2_ACTION {
SET_SUBJECT = "SET_SUBJECT",
CLEAR_SUBJECT = "CLEAR_SUBJECT",
SET_LOCATION_NEARBY = "SET_LOCATION_NEARBY",
SET_LOCATION_WORLDWIDE = "SET_LOCATION_WORLDWIDE",
SET_LOCATION_PLACE = "SET_LOCATION_PLACE",
SET_SORT = "SET_SORT",
SET_FILTERS = "SET_FILTERS",
RESET = "RESET"
}
export enum EXPLORE_V2_PLACE_MODE {
UNINITIALIZED = "UNINITIALIZED",
NEARBY = "NEARBY",
WORLDWIDE = "WORLDWIDE",
PLACE = "PLACE"
}
// more options to be potentially added in MOB-1333
export enum EXPLORE_V2_SORT {
DATE_UPLOADED_NEWEST = "DATE_UPLOADED_NEWEST",
DATE_UPLOADED_OLDEST = "DATE_UPLOADED_OLDEST",
DATE_OBSERVED_NEWEST = "DATE_OBSERVED_NEWEST",
DATE_OBSERVED_OLDEST = "DATE_OBSERVED_OLDEST",
MOST_FAVED = "MOST_FAVED"
}
interface Place {
id: number;
display_name?: string;
}
interface Taxon {
id: number;
name: string;
}
interface User {
id: number;
login: string;
}
interface Project {
id: number;
title: string;
}
export type ExploreV2Subject =
| { type: "taxon"; taxon: Taxon }
| { type: "user"; user: User }
| { type: "project"; project: Project };
// To be added to in MOB-1346
export interface ExploreV2Filters {
[key: string]: unknown;
}
export type ExploreV2LocationState =
| { placeMode: EXPLORE_V2_PLACE_MODE.UNINITIALIZED }
| { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE }
| {
placeMode: EXPLORE_V2_PLACE_MODE.NEARBY;
lat: number;
lng: number;
radius: number;
}
| { placeMode: EXPLORE_V2_PLACE_MODE.PLACE; place: Place };
export interface ExploreV2State {
subject: ExploreV2Subject | null;
location: ExploreV2LocationState;
sortBy: EXPLORE_V2_SORT;
filters: ExploreV2Filters;
}
export type ExploreV2Action =
| { type: EXPLORE_V2_ACTION.SET_SUBJECT; subject: ExploreV2Subject }
| { type: EXPLORE_V2_ACTION.CLEAR_SUBJECT }
| {
type: EXPLORE_V2_ACTION.SET_LOCATION_NEARBY;
lat: number;
lng: number;
radius: number;
}
| { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE }
| {
type: EXPLORE_V2_ACTION.SET_LOCATION_PLACE;
place: Place;
}
| { type: EXPLORE_V2_ACTION.SET_SORT; sortBy: EXPLORE_V2_SORT }
| { type: EXPLORE_V2_ACTION.SET_FILTERS; filters: ExploreV2Filters }
| { type: EXPLORE_V2_ACTION.RESET };
export const initialExploreV2State: ExploreV2State = {
subject: null,
location: { placeMode: EXPLORE_V2_PLACE_MODE.UNINITIALIZED },
sortBy: EXPLORE_V2_SORT.DATE_UPLOADED_NEWEST,
filters: {},
};
export function exploreV2Reducer(
state: ExploreV2State,
action: ExploreV2Action,
): ExploreV2State {
switch ( action.type ) {
case EXPLORE_V2_ACTION.SET_SUBJECT:
return { ...state, subject: action.subject };
case EXPLORE_V2_ACTION.CLEAR_SUBJECT:
return { ...state, subject: null };
case EXPLORE_V2_ACTION.SET_LOCATION_NEARBY:
return {
...state,
location: {
placeMode: EXPLORE_V2_PLACE_MODE.NEARBY,
lat: action.lat,
lng: action.lng,
radius: action.radius,
},
};
case EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE:
return {
...state,
location: { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE },
};
case EXPLORE_V2_ACTION.SET_LOCATION_PLACE:
return {
...state,
location: {
placeMode: EXPLORE_V2_PLACE_MODE.PLACE,
place: action.place,
},
};
case EXPLORE_V2_ACTION.SET_SORT:
return { ...state, sortBy: action.sortBy };
case EXPLORE_V2_ACTION.SET_FILTERS:
return { ...state, filters: action.filters };
case EXPLORE_V2_ACTION.RESET:
return initialExploreV2State;
default: {
// https://www.typescriptlang.org/docs/handbook/2/narrowing.html#exhaustiveness-checking
const _exhaustive: never = action;
return _exhaustive;
}
}
}
export type DefaultExploreV2Location =
| { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE }
| {
placeMode: EXPLORE_V2_PLACE_MODE.NEARBY;
lat: number;
lng: number;
radius: number;
};
export async function defaultExploreV2Location( ): Promise<DefaultExploreV2Location> {
const location = await fetchCoarseUserLocation( );
if ( !location || !location.latitude ) {
return { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE };
}
return {
placeMode: EXPLORE_V2_PLACE_MODE.NEARBY,
lat: location.latitude,
lng: location.longitude,
radius: 1,
};
}
interface ExploreV2ContextValue {
state: ExploreV2State;
dispatch: ( action: ExploreV2Action ) => void;
}
const ExploreV2Context = React.createContext<ExploreV2ContextValue | undefined>(
undefined,
);
interface ExploreV2ProviderProps {
children: React.ReactNode;
}
export const ExploreV2Provider = ( { children }: ExploreV2ProviderProps ) => {
const [state, dispatch] = React.useReducer( exploreV2Reducer, initialExploreV2State );
const value = React.useMemo(
() => ( { state, dispatch } ),
[state],
);
return (
<ExploreV2Context value={value}>
{children}
</ExploreV2Context>
);
};
export function useExploreV2( ): ExploreV2ContextValue {
const context = React.useContext( ExploreV2Context );
// Pattern from https://kentcdodds.com/blog/how-to-use-react-context-effectively
if ( context === undefined ) {
throw new Error( "useExploreV2 must be used within an ExploreV2Provider" );
}
return context;
}

View File

@@ -0,0 +1,167 @@
import buildExploreV2QueryParams
from "components/Explore/ExploreV2/buildQueryParams";
import {
EXPLORE_V2_PLACE_MODE,
EXPLORE_V2_SORT,
initialExploreV2State,
} from "providers/ExploreV2Context";
describe( "buildExploreV2QueryParams", ( ) => {
describe( "subject filter", ( ) => {
it( "maps a selected taxon to taxon_id", ( ) => {
const state = {
...initialExploreV2State,
subject: { type: "taxon", taxon: { id: 42 } },
};
const params = buildExploreV2QueryParams( state );
expect( params.taxon_id ).toBe( 42 );
expect( params.user_id ).toBeUndefined( );
expect( params.project_id ).toBeUndefined( );
} );
it( "maps a selected user to user_id", ( ) => {
const state = {
...initialExploreV2State,
subject: { type: "user", user: { id: 7 } },
};
const params = buildExploreV2QueryParams( state );
expect( params.user_id ).toBe( 7 );
expect( params.taxon_id ).toBeUndefined( );
} );
it( "maps a selected project to project_id", ( ) => {
const state = {
...initialExploreV2State,
subject: { type: "project", project: { id: 12 } },
};
const params = buildExploreV2QueryParams( state );
expect( params.project_id ).toBe( 12 );
} );
} );
describe( "location", ( ) => {
it( "includes lat/lng/radius in NEARBY mode with coords", ( ) => {
const state = {
...initialExploreV2State,
location: {
placeMode: EXPLORE_V2_PLACE_MODE.NEARBY,
lat: 37.5,
lng: -122.1,
radius: 1,
},
};
const params = buildExploreV2QueryParams( state );
expect( params.lat ).toBe( 37.5 );
expect( params.lng ).toBe( -122.1 );
expect( params.radius ).toBe( 1 );
expect( params.place_id ).toBeUndefined( );
} );
it( "omits coords and place in WORLDWIDE mode", ( ) => {
const state = {
...initialExploreV2State,
location: { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE },
};
const params = buildExploreV2QueryParams( state );
expect( params.lat ).toBeUndefined( );
expect( params.place_id ).toBeUndefined( );
} );
it( "uses place_id in PLACE mode", ( ) => {
const state = {
...initialExploreV2State,
location: {
placeMode: EXPLORE_V2_PLACE_MODE.PLACE,
place: { id: 5 },
},
};
const params = buildExploreV2QueryParams( state );
expect( params.place_id ).toBe( 5 );
expect( params.lat ).toBeUndefined( );
} );
} );
describe( "sort", ( ) => {
it( "DATE_UPLOADED_NEWEST → created_at desc", ( ) => {
const params = buildExploreV2QueryParams( {
...initialExploreV2State,
sortBy: EXPLORE_V2_SORT.DATE_UPLOADED_NEWEST,
} );
expect( params.order_by ).toBe( "created_at" );
expect( params.order ).toBe( "desc" );
} );
it( "DATE_UPLOADED_OLDEST → created_at asc", ( ) => {
const params = buildExploreV2QueryParams( {
...initialExploreV2State,
sortBy: EXPLORE_V2_SORT.DATE_UPLOADED_OLDEST,
} );
expect( params.order_by ).toBe( "created_at" );
expect( params.order ).toBe( "asc" );
} );
it( "DATE_OBSERVED_NEWEST → observed_on desc", ( ) => {
const params = buildExploreV2QueryParams( {
...initialExploreV2State,
sortBy: EXPLORE_V2_SORT.DATE_OBSERVED_NEWEST,
} );
expect( params.order_by ).toBe( "observed_on" );
expect( params.order ).toBe( "desc" );
} );
it( "DATE_OBSERVED_OLDEST → observed_on asc", ( ) => {
const params = buildExploreV2QueryParams( {
...initialExploreV2State,
sortBy: EXPLORE_V2_SORT.DATE_OBSERVED_OLDEST,
} );
expect( params.order_by ).toBe( "observed_on" );
expect( params.order ).toBe( "asc" );
} );
it( "MOST_FAVED → votes desc", ( ) => {
const params = buildExploreV2QueryParams( {
...initialExploreV2State,
sortBy: EXPLORE_V2_SORT.MOST_FAVED,
} );
expect( params.order_by ).toBe( "votes" );
expect( params.order ).toBe( "desc" );
} );
} );
it( "always sets per_page to 20 and verifiable to true", ( ) => {
const params = buildExploreV2QueryParams( initialExploreV2State );
expect( params.per_page ).toBe( 20 );
expect( params.verifiable ).toBe( true );
} );
it( "applies sort order to the default state", ( ) => {
const params = buildExploreV2QueryParams( initialExploreV2State );
expect( params.order_by ).toBe( "created_at" );
expect( params.order ).toBe( "desc" );
} );
it( "combines subject, location, and sort into a single query", ( ) => {
const state = {
subject: { type: "taxon", taxon: { id: 42 } },
location: {
placeMode: EXPLORE_V2_PLACE_MODE.NEARBY,
lat: 37.5,
lng: -122.1,
radius: 1,
},
sortBy: EXPLORE_V2_SORT.MOST_FAVED,
filters: {},
};
const params = buildExploreV2QueryParams( state );
expect( params ).toEqual( {
per_page: 20,
verifiable: true,
order_by: "votes",
order: "desc",
taxon_id: 42,
lat: 37.5,
lng: -122.1,
radius: 1,
} );
} );
} );

View File

@@ -0,0 +1,226 @@
import {
defaultExploreV2Location,
EXPLORE_V2_ACTION,
EXPLORE_V2_PLACE_MODE,
EXPLORE_V2_SORT,
exploreV2Reducer,
initialExploreV2State,
} from "providers/ExploreV2Context";
import fetchCoarseUserLocation from "sharedHelpers/fetchCoarseUserLocation";
jest.mock( "sharedHelpers/fetchCoarseUserLocation", ( ) => ( {
__esModule: true,
default: jest.fn( ),
} ) );
describe( "initialExploreV2State", ( ) => {
it( "starts with no subject, UNINITIALIZED placeMode, newest-upload sort, empty filters", ( ) => {
expect( initialExploreV2State.subject ).toBeNull( );
expect( initialExploreV2State.location.placeMode ).toBe( EXPLORE_V2_PLACE_MODE.UNINITIALIZED );
expect( initialExploreV2State.sortBy ).toBe( EXPLORE_V2_SORT.DATE_UPLOADED_NEWEST );
expect( initialExploreV2State.filters ).toEqual( {} );
} );
} );
describe( "exploreV2Reducer", ( ) => {
describe( EXPLORE_V2_ACTION.SET_SUBJECT, ( ) => {
it( "sets a taxon subject, replacing any prior subject", ( ) => {
const taxon = { id: 42, name: "Foo" };
const state = {
...initialExploreV2State,
subject: { type: "user", user: { id: 1 } },
};
const next = exploreV2Reducer( state, {
type: EXPLORE_V2_ACTION.SET_SUBJECT,
subject: { type: "taxon", taxon },
} );
expect( next.subject ).toEqual( { type: "taxon", taxon } );
} );
it( "preserves location, sortBy, and filters when changing subject", ( ) => {
const state = {
subject: null,
location: {
placeMode: EXPLORE_V2_PLACE_MODE.NEARBY,
lat: 1,
lng: 2,
radius: 3,
},
sortBy: EXPLORE_V2_SORT.MOST_FAVED,
filters: { quality_grade: "research" },
};
const next = exploreV2Reducer( state, {
type: EXPLORE_V2_ACTION.SET_SUBJECT,
subject: { type: "taxon", taxon: { id: 1 } },
} );
expect( next.location ).toEqual( state.location );
expect( next.sortBy ).toBe( state.sortBy );
expect( next.filters ).toEqual( state.filters );
} );
} );
describe( EXPLORE_V2_ACTION.CLEAR_SUBJECT, ( ) => {
it( "clears the selected subject", ( ) => {
const state = {
...initialExploreV2State,
subject: { type: "taxon", taxon: { id: 99 } },
};
const next = exploreV2Reducer( state, { type: EXPLORE_V2_ACTION.CLEAR_SUBJECT } );
expect( next.subject ).toBeNull( );
} );
} );
describe( "location actions", ( ) => {
it( "SET_LOCATION_NEARBY transitions from PLACE and drops place", ( ) => {
const state = {
...initialExploreV2State,
location: {
placeMode: EXPLORE_V2_PLACE_MODE.PLACE,
place: { id: 1 },
},
};
const next = exploreV2Reducer( state, {
type: EXPLORE_V2_ACTION.SET_LOCATION_NEARBY,
lat: 37.5,
lng: -122.1,
radius: 1,
} );
expect( next.location.placeMode ).toBe( EXPLORE_V2_PLACE_MODE.NEARBY );
expect( next.location.lat ).toBe( 37.5 );
expect( next.location.lng ).toBe( -122.1 );
expect( next.location.radius ).toBe( 1 );
expect( next.location.place ).toBeUndefined( );
} );
it( "SET_LOCATION_WORLDWIDE transitions from NEARBY and drops coords", ( ) => {
const state = {
...initialExploreV2State,
location: {
placeMode: EXPLORE_V2_PLACE_MODE.NEARBY,
lat: 1,
lng: 1,
radius: 1,
},
};
const next = exploreV2Reducer( state, {
type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE,
} );
expect( next.location.placeMode ).toBe( EXPLORE_V2_PLACE_MODE.WORLDWIDE );
expect( next.location.lat ).toBeUndefined( );
expect( next.location.lng ).toBeUndefined( );
expect( next.location.radius ).toBeUndefined( );
} );
it( "SET_LOCATION_PLACE transitions from NEARBY and drops coords", ( ) => {
const state = {
...initialExploreV2State,
location: {
placeMode: EXPLORE_V2_PLACE_MODE.NEARBY,
lat: 1,
lng: 1,
radius: 1,
},
};
const place = { id: 5, display_name: "Oakland" };
const next = exploreV2Reducer( state, {
type: EXPLORE_V2_ACTION.SET_LOCATION_PLACE,
place,
} );
expect( next.location.placeMode ).toBe( EXPLORE_V2_PLACE_MODE.PLACE );
expect( next.location.place ).toEqual( place );
expect( next.location.lat ).toBeUndefined( );
} );
it( "SET_LOCATION_PLACE replaces an existing place", ( ) => {
const state = {
...initialExploreV2State,
location: {
placeMode: EXPLORE_V2_PLACE_MODE.PLACE,
place: { id: 1, display_name: "Oakland" },
},
};
const place = { id: 2, display_name: "Berkeley" };
const next = exploreV2Reducer( state, {
type: EXPLORE_V2_ACTION.SET_LOCATION_PLACE,
place,
} );
expect( next.location.placeMode ).toBe( EXPLORE_V2_PLACE_MODE.PLACE );
expect( next.location.place ).toEqual( place );
} );
it( "preserves subject, sortBy, and filters when changing location", ( ) => {
const state = {
subject: { type: "taxon", taxon: { id: 42 } },
location: { placeMode: EXPLORE_V2_PLACE_MODE.UNINITIALIZED },
sortBy: EXPLORE_V2_SORT.MOST_FAVED,
filters: { quality_grade: "research" },
};
const next = exploreV2Reducer( state, {
type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE,
} );
expect( next.subject ).toEqual( state.subject );
expect( next.sortBy ).toBe( state.sortBy );
expect( next.filters ).toEqual( state.filters );
} );
} );
describe( EXPLORE_V2_ACTION.SET_SORT, ( ) => {
it( "updates sortBy", ( ) => {
const next = exploreV2Reducer( initialExploreV2State, {
type: EXPLORE_V2_ACTION.SET_SORT,
sortBy: EXPLORE_V2_SORT.DATE_OBSERVED_NEWEST,
} );
expect( next.sortBy ).toBe( EXPLORE_V2_SORT.DATE_OBSERVED_NEWEST );
} );
} );
describe( EXPLORE_V2_ACTION.SET_FILTERS, ( ) => {
it( "replaces filters with the provided object", ( ) => {
const state = {
...initialExploreV2State,
filters: { quality_grade: "research" },
};
const filters = { quality_grade: "needs_id", iconic_taxa: ["Aves"] };
const next = exploreV2Reducer( state, {
type: EXPLORE_V2_ACTION.SET_FILTERS,
filters,
} );
expect( next.filters ).toEqual( filters );
} );
} );
describe( EXPLORE_V2_ACTION.RESET, ( ) => {
it( "returns initial state", ( ) => {
const state = {
...initialExploreV2State,
subject: { type: "taxon", taxon: { id: 1 } },
sortBy: EXPLORE_V2_SORT.MOST_FAVED,
};
const next = exploreV2Reducer( state, { type: EXPLORE_V2_ACTION.RESET } );
expect( next ).toEqual( initialExploreV2State );
} );
} );
} );
describe( "defaultExploreV2Location", ( ) => {
beforeEach( ( ) => {
fetchCoarseUserLocation.mockReset( );
} );
it( "returns NEARBY with radius 1 when a location is available", async ( ) => {
fetchCoarseUserLocation.mockResolvedValueOnce( { latitude: 37.5, longitude: -122.1 } );
const result = await defaultExploreV2Location( );
expect( result ).toEqual( {
placeMode: EXPLORE_V2_PLACE_MODE.NEARBY,
lat: 37.5,
lng: -122.1,
radius: 1,
} );
} );
it( "returns WORLDWIDE when fetchCoarseUserLocation returns null", async ( ) => {
fetchCoarseUserLocation.mockResolvedValueOnce( null );
const result = await defaultExploreV2Location( );
expect( result ).toEqual( { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE } );
} );
} );