mirror of
https://github.com/inaturalist/iNaturalistReactNative.git
synced 2026-05-24 16:32:58 -04:00
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:
64
src/components/Explore/ExploreV2/ExploreV2Container.tsx
Normal file
64
src/components/Explore/ExploreV2/ExploreV2Container.tsx
Normal 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;
|
||||
81
src/components/Explore/ExploreV2/buildQueryParams.ts
Normal file
81
src/components/Explore/ExploreV2/buildQueryParams.ts
Normal 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;
|
||||
14
src/components/Explore/ExploreV2/screens/AdvancedSearch.tsx
Normal file
14
src/components/Explore/ExploreV2/screens/AdvancedSearch.tsx
Normal 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;
|
||||
55
src/components/Explore/ExploreV2/screens/ExploreResults.tsx
Normal file
55
src/components/Explore/ExploreV2/screens/ExploreResults.tsx
Normal 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;
|
||||
14
src/components/Explore/ExploreV2/screens/UniversalSearch.tsx
Normal file
14
src/components/Explore/ExploreV2/screens/UniversalSearch.tsx
Normal 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;
|
||||
36
src/navigation/StackNavigators/ExploreStackNavigator.tsx
Normal file
36
src/navigation/StackNavigators/ExploreStackNavigator.tsx
Normal 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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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>,
|
||||
|
||||
210
src/providers/ExploreV2Context.tsx
Normal file
210
src/providers/ExploreV2Context.tsx
Normal 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;
|
||||
}
|
||||
167
tests/unit/components/Explore/ExploreV2/buildQueryParams.test.js
Normal file
167
tests/unit/components/Explore/ExploreV2/buildQueryParams.test.js
Normal 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,
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
226
tests/unit/providers/ExploreV2Context.test.js
Normal file
226
tests/unit/providers/ExploreV2Context.test.js
Normal 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 } );
|
||||
} );
|
||||
} );
|
||||
Reference in New Issue
Block a user