merge main

This commit is contained in:
sepeterson
2026-07-22 20:21:50 -05:00
63 changed files with 1885 additions and 1330 deletions

View File

@@ -13,7 +13,22 @@ concurrency:
cancel-in-progress: true
jobs:
checksecret:
name: check for secrets presence
runs-on: ubuntu-latest
outputs:
is_SECRETS_PRESENT_set: ${{ steps.checksecret_job.outputs.is_SECRETS_PRESENT_set }}
steps:
- name: Check whether secrets are present in the action runner
id: checksecret_job
env:
SECRETS_PRESENT: ${{ secrets.OAUTH_CLIENT_SECRET }}
run: |
echo "is_SECRETS_PRESENT_set: ${{ env.SECRETS_PRESENT != '' }}"
echo "::set-output name=is_SECRETS_PRESENT_set::${{ env.SECRETS_PRESENT != '' }}"
build:
needs: checksecret
if: needs.checksecret.outputs.is_SECRETS_PRESENT_set == 'true'
# 4-core Ubunutu GitHub Larger Runner
# https://docs.github.com/en/enterprise-cloud@latest/billing/reference/actions-runner-pricing#x64-powered-larger-runners
runs-on: ubuntu-24.04-m

View File

@@ -15,12 +15,12 @@ concurrency:
jobs:
checksecret:
name: check for oauth client
name: check for secrets presence
runs-on: macos-26
outputs:
is_SECRETS_PRESENT_set: ${{ steps.checksecret_job.outputs.is_SECRETS_PRESENT_set }}
steps:
- name: Check whether unity activation requests should be done
- name: Check whether secrets are present in the action runner
id: checksecret_job
env:
SECRETS_PRESENT: ${{ secrets.OAUTH_CLIENT_SECRET }}

View File

Binary file not shown.

View File

@@ -3,7 +3,7 @@
"data": [
{
"path": "assets/fonts/INatIcon.ttf",
"sha1": "89aa6f39308fa5bad4edbdd509983df51f06f30b"
"sha1": "35ca4fba9e8a1542c0ee4a06d4b5035627d92cbb"
},
{
"path": "assets/fonts/Lato-Bold.ttf",

View File

@@ -31,9 +31,9 @@
8F346E4A2CF6912700CED7B4 /* geomodel.mlmodel in Sources */ = {isa = PBXBuildFile; fileRef = 8F346E492CF6912700CED7B4 /* geomodel.mlmodel */; };
8FE171A22E97F0780003E759 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 8FE171A12E97F0780003E759 /* GoogleService-Info.plist */; };
8FF73F732F69C8CE007CF4F0 /* AutoContinueShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FF73F722F69C8CE007CF4F0 /* AutoContinueShareViewController.swift */; };
99CC9E26994B44F7B52A1B5D /* INatIcon.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 8B9F9C9225124178BD456CA4 /* INatIcon.ttf */; };
AE4DC81B3A87484CB3FD6750 /* Lato-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 4B0AEEF6CA584BCF9880EB35 /* Lato-Regular.ttf */; };
E5DFC1C6FBFA45739CE91C69 /* Lato-MediumItalic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 69DF855D92EA4ADFB73B47F1 /* Lato-MediumItalic.ttf */; };
D224B9770DAC4D9098E09BFF /* INatIcon.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 54EEE7CC3E4645E4B9CE1AA8 /* INatIcon.ttf */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -92,7 +92,6 @@
8B65ED3A29F575FE0054CCEF /* ShareViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ShareViewController.swift; path = "../../node_modules/react-native-share-menu/ios/ShareViewController.swift"; sourceTree = "<group>"; };
8B65ED3C29F576D00054CCEF /* iNaturalistReactNative-ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "iNaturalistReactNative-ShareExtension.entitlements"; sourceTree = "<group>"; };
8B8BAD0429F54EB300CE5C9F /* iNaturalistReactNative.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = iNaturalistReactNative.entitlements; path = iNaturalistReactNative/iNaturalistReactNative.entitlements; sourceTree = "<group>"; };
8B9F9C9225124178BD456CA4 /* INatIcon.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = INatIcon.ttf; path = ../assets/fonts/INatIcon.ttf; sourceTree = "<group>"; };
8C2D97D72EED451C887998A8 /* Lato-BoldItalic.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Lato-BoldItalic.ttf"; path = "../assets/fonts/Lato-BoldItalic.ttf"; sourceTree = "<group>"; };
8F1AC6762BC1B610002F994B /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
8F346E492CF6912700CED7B4 /* geomodel.mlmodel */ = {isa = PBXFileReference; lastKnownFileType = file.mlmodel; path = geomodel.mlmodel; sourceTree = "<group>"; };
@@ -100,6 +99,7 @@
8FF73F722F69C8CE007CF4F0 /* AutoContinueShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AutoContinueShareViewController.swift; path = "../node_modules/react-native-share-menu/ios/AutoContinueShareViewController.swift"; sourceTree = SOURCE_ROOT; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
F15C1390617A309CE0A194B2 /* Pods_iNaturalistReactNative_ShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iNaturalistReactNative_ShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
54EEE7CC3E4645E4B9CE1AA8 /* INatIcon.ttf */ = {isa = PBXFileReference; name = "INatIcon.ttf"; path = "../assets/fonts/INatIcon.ttf"; sourceTree = "<group>"; fileEncoding = undefined; lastKnownFileType = unknown; explicitFileType = undefined; includeInIndex = 0; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -229,7 +229,7 @@
3A9BAF07FCF24F668E2EF5AB /* Lato-Medium.ttf */,
69DF855D92EA4ADFB73B47F1 /* Lato-MediumItalic.ttf */,
4B0AEEF6CA584BCF9880EB35 /* Lato-Regular.ttf */,
8B9F9C9225124178BD456CA4 /* INatIcon.ttf */,
54EEE7CC3E4645E4B9CE1AA8 /* INatIcon.ttf */,
);
name = Resources;
sourceTree = "<group>";
@@ -338,7 +338,7 @@
E5DFC1C6FBFA45739CE91C69 /* Lato-MediumItalic.ttf in Resources */,
AE4DC81B3A87484CB3FD6750 /* Lato-Regular.ttf in Resources */,
716F7BDCD8B943479083CFAC /* Settings.bundle in Resources */,
99CC9E26994B44F7B52A1B5D /* INatIcon.ttf in Resources */,
D224B9770DAC4D9098E09BFF /* INatIcon.ttf in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -3,7 +3,7 @@
"data": [
{
"path": "assets/fonts/INatIcon.ttf",
"sha1": "89aa6f39308fa5bad4edbdd509983df51f06f30b"
"sha1": "35ca4fba9e8a1542c0ee4a06d4b5035627d92cbb"
},
{
"path": "assets/fonts/Lato-Bold.ttf",

6
package-lock.json generated
View File

@@ -23153,9 +23153,9 @@
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/websocket-driver": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",
"integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==",
"version": "0.7.5",
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz",
"integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==",
"license": "Apache-2.0",
"dependencies": {
"http-parser-js": ">=0.5.1",

View File

@@ -1,27 +1,34 @@
import type { ErrorWithResponse, INatApiError } from "api/error";
import handleError from "api/error";
import type {
ApiDefaultResult, ApiGetByIdParams, ApiOpts, ApiParams, ApiResponse,
} from "api/types";
import inatjs from "inaturalistjs";
const fetchUserPosts = async (
params: Record<string, unknown> = {},
opts: Record<string, unknown> = {},
): Promise<Record<string, unknown> | null | ErrorWithResponse | INatApiError> => {
const fetchBlogPosts = async <T = ApiDefaultResult>(
params: ApiParams = {},
opts: ApiOpts = {},
): Promise<ApiResponse<T> | null | ErrorWithResponse | INatApiError> => {
try {
return await inatjs.posts.for_user( params, opts );
const response = await inatjs.posts.for_user( params, opts );
if ( !response ) { return null; }
return response;
} catch ( e ) {
return handleError(
e as ErrorWithResponse,
{ context: { functionName: "fetchUserPosts", opts } },
{ context: { functionName: "fetchBlogPosts", opts } },
);
}
};
const fetchProjectPosts = async (
params: Record<string, unknown> = {},
opts: Record<string, unknown> = {},
): Promise<Record<string, unknown> | null | ErrorWithResponse | INatApiError> => {
const fetchProjectPosts = async <T = ApiDefaultResult>(
params: ApiGetByIdParams,
opts: ApiOpts = {},
): Promise<ApiResponse<T> | null | ErrorWithResponse | INatApiError> => {
try {
return await inatjs.projects.posts( params, opts );
const response = await inatjs.projects.posts( params, opts );
if ( !response ) { return null; }
return response;
} catch ( e ) {
return handleError(
e as ErrorWithResponse,
@@ -30,7 +37,24 @@ const fetchProjectPosts = async (
}
};
const fetchUserPosts = async <T = ApiDefaultResult>(
params: ApiGetByIdParams,
opts: ApiOpts = {},
): Promise<ApiResponse<T> | null | ErrorWithResponse | INatApiError> => {
try {
const response = await inatjs.users.posts( params, opts );
if ( !response ) { return null; }
return response;
} catch ( e ) {
return handleError(
e as ErrorWithResponse,
{ context: { functionName: "fetchUserPosts", opts } },
);
}
};
export {
fetchBlogPosts,
fetchProjectPosts,
fetchUserPosts,
};

9
src/api/types.d.ts vendored
View File

@@ -14,6 +14,10 @@ export interface ApiParams {
ttl?: number;
}
export interface ApiGetByIdParams extends ApiParams {
id: number;
}
export interface ApiPlace {
id?: number;
name?: string;
@@ -21,7 +25,7 @@ export interface ApiPlace {
place_type?: number | null;
}
export interface ApiPostForProject {
export interface ApiPost {
body: string;
id: number;
published_at: string;
@@ -29,7 +33,7 @@ export interface ApiPostForProject {
}
// When using POST_FOR_USER_FIELDS
export interface ApiPostForUser extends ApiPostForProject {
export interface ApiPostForUser extends ApiPost {
parent: {
id: number;
icon_url: string | null;
@@ -293,4 +297,5 @@ export interface ApiObservationsSearchParams extends ApiParams {
typeof ORDER_BY_UPDATED_AT |
typeof ORDER_BY_VOTES;
return_bounds?: boolean;
unobserved_by_user_id?: number;
}

View File

@@ -1,17 +1,12 @@
import type { ErrorWithResponse, INatApiError } from "api/error";
import handleError from "api/error";
import type {
ApiDefaultResult, ApiGetByIdParams, ApiOpts, ApiResponse,
} from "api/types";
import inatjs from "inaturalistjs";
import type { ErrorWithResponse, INatApiError } from "./error";
import handleError from "./error";
import type {
ApiDefaultResult, ApiOpts, ApiParams, ApiResponse,
} from "./types";
interface UsersProjectsParams extends ApiParams {
id: number;
}
const fetchUserProjects = async <T = ApiDefaultResult>(
params: UsersProjectsParams,
params: ApiGetByIdParams,
opts: ApiOpts = {},
): Promise<ApiResponse<T> | null | ErrorWithResponse | INatApiError> => {
try {

View File

@@ -16,7 +16,9 @@ import React, { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { ListRenderItem } from "react-native";
import Project from "realmModels/Project";
import type { RealmProject, RealmProjectObservation } from "realmModels/types";
import type { RealmProject } from "realmModels/types";
import validateProjectFieldsForObservation from "sharedHelpers/validateProjectFieldsForObservation";
import type { ObservationFlowSlice } from "stores/createObservationFlowSlice";
import useStore from "stores/useStore";
import { getShadow } from "styles/global";
import colors from "styles/tailwindColors";
@@ -46,9 +48,10 @@ const AddToProjects = ( ) => {
},
[],
);
const projectObservations = useStore(
state => state.currentObservation?.projectObservations,
const currentObservation = useStore(
( state: ObservationFlowSlice ) => state.currentObservation,
);
const [selectedProjectIds, setSelectedProjectIds] = useState( () => new Set( ) );
const joinedProjects = useMemo(
@@ -56,6 +59,13 @@ const AddToProjects = ( ) => {
[joinedProjectsCollection],
);
const validationResult = useMemo(
() => {
const selectedProjects = joinedProjects.filter( jp => selectedProjectIds.has( jp.id ) );
return validateProjectFieldsForObservation( currentObservation, selectedProjects );
},
[currentObservation, joinedProjects, selectedProjectIds],
);
const listHeaderComponent = useMemo(
( ) => (
<View className="px-4 pt-5 pb-6">
@@ -116,13 +126,11 @@ const AddToProjects = ( ) => {
const onSave = ( ) => {
navigation.goBack( );
};
const disabled = false;
const renderExpanded = useCallback(
( item: RealmProject ) => (
( item: RealmProject, projectValid: boolean ) => (
<View className="bg-lightGrayOpaque">
{/* TODO: MOB-1499 this will be based on the result of a validation function */}
{Math.random() > 0.5
{!projectValid
? (
<View className="px-4 py-2.5 flex-row justify-center items-center">
<INatIcon
@@ -151,28 +159,24 @@ const AddToProjects = ( ) => {
<ObservationFieldInput
key={pof.id}
projectObservationField={pof}
// TODO: MOB-1499 this will be based on the result of a validation function
isValid={false}
isValid={!validationResult.errors.some(
error => error.obsFieldId === pof.obsField?.id,
)}
/>
) )}
</View>
),
[t],
[validationResult, t],
);
const renderRightIcon = useCallback(
( item: RealmProject, isSelected: boolean ) => {
// Logic if all required fields have been filled out will live in zustand
if (
projectObservations?.some(
( po: RealmProjectObservation ) => po.projectId === item.id,
)
) {
return (
<INatIcon name="checkmark-circle" color={colors.darkGray} size={24} />
);
}
( isSelected: boolean, projectValid: boolean ) => {
if ( isSelected ) {
if ( projectValid ) {
return (
<INatIcon name="checkmark-circle" color={colors.darkGray} size={24} />
);
}
return (
<INatIcon
name="circle-dots-pencil"
@@ -183,13 +187,16 @@ const AddToProjects = ( ) => {
}
return <INatIcon name="circle" color={colors.darkGray} size={24} />;
},
[projectObservations],
[],
);
const renderProject: ListRenderItem<RealmProject> = useCallback(
( { item } ) => {
const isSelected = selectedProjectIds.has( item.id );
const canExpand = item.projectObservationFields.length > 0;
const projectValid = !validationResult.errors.some(
error => error.projectId === item?.id,
);
return (
<View>
@@ -205,13 +212,13 @@ const AddToProjects = ( ) => {
<View className="flex-1 mr-2.5">
<ProjectListItem item={item} />
</View>
{renderRightIcon( item, isSelected )}
{renderRightIcon( isSelected, projectValid )}
</Pressable>
{canExpand && isSelected && renderExpanded( item )}
{canExpand && isSelected && renderExpanded( item, projectValid )}
</View>
);
},
[renderExpanded, renderRightIcon, selectedProjectIds, toggleProject],
[renderExpanded, renderRightIcon, selectedProjectIds, toggleProject, validationResult],
);
return (
@@ -237,7 +244,7 @@ const AddToProjects = ( ) => {
text={t( "SAVE" )}
onPress={onSave}
level="neutral"
disabled={disabled}
disabled={!validationResult.valid}
/>
</ButtonBar>
</View>

View File

@@ -12,13 +12,11 @@ import {
} from "providers/ExploreContext";
import type { Node } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { useCurrentUser, useFeatureFlag } from "sharedHooks";
import { useCurrentUser } from "sharedHooks";
import useLocationPermission from "sharedHooks/useLocationPermission";
import { FeatureFlag } from "stores/createFeatureFlagSlice";
import useStore from "stores/useStore";
import Explore from "./Explore";
import ExploreV2 from "./ExploreV2";
import mapParamsToAPI from "./helpers/mapParamsToAPI";
import useExploreHeaderCount from "./hooks/useExploreHeaderCount";
import useParams from "./hooks/useParams";
@@ -26,7 +24,6 @@ import useParams from "./hooks/useParams";
const ExploreContainerWithContext = ( ): Node => {
const navigation = useNavigation( );
const { isConnected } = useNetInfo( );
const exploreV2Enabled = useFeatureFlag( FeatureFlag.ExploreV2Enabled );
const exploreView = useStore( state => state.exploreView );
const setExploreView = useStore( state => state.setExploreView );
@@ -137,48 +134,32 @@ const ExploreContainerWithContext = ( ): Node => {
return (
<>
{!exploreV2Enabled
? (
<Explore
canFetch={canFetch}
closeFiltersModal={closeFiltersModal}
count={count}
currentExploreView={exploreView}
setCurrentExploreView={setExploreView}
handleUpdateCount={handleUpdateCount}
hideBackButton={false}
filterByIconicTaxonUnknown={
() => dispatch( { type: EXPLORE_ACTION.FILTER_BY_ICONIC_TAXON_UNKNOWN } )
}
isConnected={isConnected}
isFetchingHeaderCount={isFetchingHeaderCount}
openFiltersModal={openFiltersModal}
queryParams={queryParams}
showFiltersModal={showFiltersModal}
updateTaxon={taxon => dispatch( { type: EXPLORE_ACTION.CHANGE_TAXON, taxon } )}
updateLocation={updateLocation}
updateUser={updateUser}
updateProject={updateProject}
placeMode={state.placeMode}
hasLocationPermissions={hasLocationPermissions}
renderLocationPermissionsGate={renderPermissionsGate}
requestLocationPermissions={requestLocationPermissions}
startFetching={startFetching}
/>
)
: (
<ExploreV2
canFetch={canFetch}
currentExploreView={exploreView}
handleUpdateCount={handleUpdateCount}
hasLocationPermissions={hasLocationPermissions}
isConnected={isConnected}
placeMode={state.placeMode}
queryParams={queryParams}
renderLocationPermissionsGate={renderPermissionsGate}
requestLocationPermissions={requestLocationPermissions}
/>
)}
<Explore
canFetch={canFetch}
closeFiltersModal={closeFiltersModal}
count={count}
currentExploreView={exploreView}
setCurrentExploreView={setExploreView}
handleUpdateCount={handleUpdateCount}
hideBackButton={false}
filterByIconicTaxonUnknown={
() => dispatch( { type: EXPLORE_ACTION.FILTER_BY_ICONIC_TAXON_UNKNOWN } )
}
isConnected={isConnected}
isFetchingHeaderCount={isFetchingHeaderCount}
openFiltersModal={openFiltersModal}
queryParams={queryParams}
showFiltersModal={showFiltersModal}
updateTaxon={taxon => dispatch( { type: EXPLORE_ACTION.CHANGE_TAXON, taxon } )}
updateLocation={updateLocation}
updateUser={updateUser}
updateProject={updateProject}
placeMode={state.placeMode}
hasLocationPermissions={hasLocationPermissions}
renderLocationPermissionsGate={renderPermissionsGate}
requestLocationPermissions={requestLocationPermissions}
startFetching={startFetching}
/>
{renderPermissionsGate( {
onPermissionGranted: startFetching,
} ) }

View File

@@ -1,58 +0,0 @@
import { useNavigation } from "@react-navigation/native";
import type { ApiProject } from "api/types";
import {
ExploreProvider,
} from "providers/ExploreContext";
import React from "react";
import FilterModalV2 from "./Modals/FilterModalV2";
const ExploreFiltersContainerWithContext = () => {
const navigation = useNavigation();
const closeModal = () => {
navigation.goBack();
};
const filterByIconicTaxonUnknown = () => {
console.log( " Not implemented in ExploreV2 yet" );
};
const updateTaxon = (
taxon: {
name: string;
} | null,
) => {
console.log( " Not implemented in ExploreV2 yet", taxon );
};
const updateUser = (
user: {
login: string;
} | null,
) => {
console.log( " Not implemented in ExploreV2 yet", user );
};
const updateProject = ( project: ApiProject ) => {
console.log( " Not implemented in ExploreV2 yet", project );
};
return (
<FilterModalV2
closeModal={closeModal}
filterByIconicTaxonUnknown={filterByIconicTaxonUnknown}
updateTaxon={updateTaxon}
updateUser={updateUser}
updateProject={updateProject}
/>
);
};
const ExploreFiltersContainer = () => (
<ExploreProvider>
<ExploreFiltersContainerWithContext />
</ExploreProvider>
);
export default ExploreFiltersContainer;

View File

@@ -1,104 +0,0 @@
import { useNavigation, useRoute } from "@react-navigation/native";
import type { ApiPlace, ApiProject, ApiTaxon } from "api/types";
import { View } from "components/styledComponents";
import type { TabStackScreenProps } from "navigation/types";
import {
ExploreProvider,
} from "providers/ExploreContext";
import React from "react";
import type { RealmTaxon } from "realmModels/types";
import { useLocationPermission } from "sharedHooks";
import ExploreLocationSearch from "./SearchScreens/ExploreLocationSearch";
import ExploreProjectSearch from "./SearchScreens/ExploreProjectSearch";
import ExploreTaxonSearch from "./SearchScreens/ExploreTaxonSearch";
import ExploreUserSearch from "./SearchScreens/ExploreUserSearch";
const ExploreSearchContainerWithContext = () => {
const navigation = useNavigation<TabStackScreenProps<"ExploreSearch">["navigation"]>();
const { params } = useRoute<TabStackScreenProps<"ExploreSearch">["route"]>();
const {
hasPermissions,
renderPermissionsGate,
requestPermissions,
} = useLocationPermission( );
const initialSearchMode = params?.initialSearchMode || "none";
const closeModal = () => {
navigation.goBack();
};
const updateTaxon = (
taxon: {
name: string;
} | null,
) => {
console.log( "Not implemented in ExploreV2 yet.", taxon );
};
const updateLocation = ( location: "worldwide" | ApiPlace ) => {
console.log( "Not implemented in ExploreV2 yet.", location );
};
const updateUser = ( user: null | { login: string } ) => {
console.log( "Not implemented in ExploreV2 yet.", user );
};
const updateProject = ( project: null | ApiProject ) => {
console.log( "Not implemented in ExploreV2 yet.", project );
};
if ( initialSearchMode === "taxon" ) {
return (
<ExploreTaxonSearch
closeModal={closeModal}
onPressInfo={( taxon: RealmTaxon | ApiTaxon ) => {
navigation.push( "TaxonDetails", { id: taxon.id } );
}}
updateTaxon={updateTaxon}
/>
);
}
if ( initialSearchMode === "location" ) {
return (
<ExploreLocationSearch
closeModal={closeModal}
hasPermissions={hasPermissions}
renderPermissionsGate={renderPermissionsGate}
requestPermissions={requestPermissions}
updateLocation={updateLocation}
/>
);
}
if ( initialSearchMode === "users" ) {
return (
<ExploreUserSearch closeModal={closeModal} updateUser={updateUser} />
);
}
if ( initialSearchMode === "projects" ) {
return (
<ExploreProjectSearch
closeModal={closeModal}
updateProject={updateProject}
/>
);
}
return (
<View>
{renderPermissionsGate( {} )}
</View>
);
};
const ExploreSearchContainer = () => (
<ExploreProvider>
<ExploreSearchContainerWithContext />
</ExploreProvider>
);
export default ExploreSearchContainer;

View File

@@ -1,202 +0,0 @@
import { refresh } from "@react-native-community/netinfo";
import { useNavigation } from "@react-navigation/native";
import classnames from "classnames";
import {
Body2,
Button,
INatIconButton,
OfflineNotice,
ViewWrapper,
} from "components/SharedComponents";
import { Pressable, View } from "components/styledComponents";
import { PLACE_MODE } from "providers/ExploreContext";
import React from "react";
import { Alert } from "react-native";
import {
useDebugMode,
useStoredLayout,
useTranslation,
} from "sharedHooks";
import type { RenderLocationPermissionsGateFunction } from "sharedHooks/useLocationPermission";
import { getShadow } from "styles/global";
import IdentifiersView from "./IdentifiersView";
import ObservationsView from "./ObservationsView";
import ObservationsViewBar from "./ObservationsViewBar";
import ObserversView from "./ObserversView";
import SpeciesView from "./SpeciesView";
const DROP_SHADOW = getShadow( {
offsetHeight: 4,
elevation: 6,
} );
enum EXPLORE_VIEW {
OBSERVATIONS = "observations",
IDENTIFIERS = "identifiers",
OBSERVERS = "observers",
SPECIES = "species"
}
enum EXPLORE_OBSERVATIONS_LAYOUT {
GRID = "grid",
LIST = "list",
MAP = "map"
}
interface Props {
canFetch?: boolean;
currentExploreView: EXPLORE_VIEW;
handleUpdateCount: ( exploreView: EXPLORE_VIEW, totalResults: number ) => void;
hasLocationPermissions?: boolean;
isConnected: boolean;
placeMode: string;
queryParams: object;
renderLocationPermissionsGate: RenderLocationPermissionsGateFunction;
requestLocationPermissions: ( ) => void;
}
const ExploreV2 = ( {
canFetch,
currentExploreView,
handleUpdateCount,
hasLocationPermissions,
isConnected,
placeMode,
queryParams,
renderLocationPermissionsGate,
requestLocationPermissions,
}: Props ) => {
const navigation = useNavigation();
const { t } = useTranslation( );
const { layout, writeLayoutToStorage } = useStoredLayout( "exploreObservationsLayout" ) as {
layout: EXPLORE_OBSERVATIONS_LAYOUT | null;
writeLayoutToStorage: ( newValue: EXPLORE_OBSERVATIONS_LAYOUT ) => void;
};
const { isDebug } = useDebugMode( );
const renderMainContent = ( ) => {
if ( isConnected === false ) {
return (
<OfflineNotice
onPress={() => refresh()}
/>
);
}
// hasLocationPermissions === undefined means we haven't checked for location permissions yet
if ( placeMode === PLACE_MODE.NEARBY && hasLocationPermissions === false ) {
return (
<View className="flex-1 justify-center p-4">
<View className="items-center">
<Body2>{t( "To-view-nearby-organisms-please-enable-location" )}</Body2>
</View>
<Button
className="mt-5"
text={t( "ALLOW-LOCATION-ACCESS" )}
accessibilityHint={t( "Opens-location-permission-prompt" )}
level="focus"
onPress={( ) => requestLocationPermissions()}
/>
</View>
);
}
return (
<View className="flex-1">
{currentExploreView === EXPLORE_VIEW.OBSERVATIONS && (
<ObservationsView
canFetch={canFetch}
layout={layout}
queryParams={queryParams}
handleUpdateCount={handleUpdateCount}
hasLocationPermissions={hasLocationPermissions}
renderLocationPermissionsGate={renderLocationPermissionsGate}
requestLocationPermissions={requestLocationPermissions}
/>
)}
{currentExploreView === EXPLORE_VIEW.SPECIES && (
<SpeciesView
canFetch={canFetch}
isConnected={isConnected}
queryParams={queryParams}
handleUpdateCount={handleUpdateCount}
/>
)}
{currentExploreView === EXPLORE_VIEW.OBSERVERS && (
<ObserversView
canFetch={canFetch}
isConnected={isConnected}
queryParams={queryParams}
handleUpdateCount={handleUpdateCount}
/>
)}
{currentExploreView === EXPLORE_VIEW.IDENTIFIERS && (
<IdentifiersView
canFetch={canFetch}
isConnected={isConnected}
queryParams={queryParams}
handleUpdateCount={handleUpdateCount}
/>
)}
</View>
);
};
return (
<>
<ViewWrapper testID="ExploreV2" wrapperClassName="overflow-hidden">
<View className="flex-1 overflow-hidden">
<Pressable
accessibilityRole="button"
onPress={() => navigation.navigate( "ExploreFilters" )}
>
{/* eslint-disable-next-line i18next/no-literal-string */}
<Body2>TODO: Header Link to Filters</Body2>
</Pressable>
{currentExploreView === "observations" && (
<ObservationsViewBar
layout={layout}
updateObservationsView={writeLayoutToStorage}
/>
)}
{renderMainContent()}
{isDebug && (
<INatIconButton
icon="triangle-exclamation"
className={classnames(
"absolute",
"bg-white",
"bottom-[100px]",
"h-[55px]",
"right-5",
"rounded-full",
"w-[55px]",
"z-10",
)}
color="white"
size={27}
style={[
DROP_SHADOW,
// eslint-disable-next-line react-native/no-inline-styles
{ backgroundColor: "deeppink" },
]}
accessibilityLabel="Diagnostics"
onPress={() => {
Alert.alert(
"ExploreV2 Info",
`queryParams: ${JSON.stringify( queryParams )}`,
);
}}
/>
)}
</View>
</ViewWrapper>
{/*
Leaving this here so that it is easier to reason about differences between Explore
and ExploreV2.
*/}
{null}
</>
);
};
export default ExploreV2;

View File

@@ -1,90 +1,12 @@
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";
import { ExploreV2Provider } from "providers/ExploreV2Context";
import React from "react";
interface ExploreV2WithProviderProps {
hasPermissions?: boolean;
hasBlockedPermissions: boolean;
}
const ExploreV2WithProvider = ( {
hasPermissions,
hasBlockedPermissions,
}: ExploreV2WithProviderProps ) => {
const { state, dispatch } = useExploreV2( );
// 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 onPermissionsResolved = useEffectEvent( async ( ) => {
const { placeMode } = state.location;
if ( hasPermissions ) {
if (
placeMode !== EXPLORE_V2_PLACE_MODE.UNINITIALIZED
&& placeMode !== EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION
) return;
// if we have location permissions
// and place mode isn't one of the viewable modes (worldwide, nearby, specfic place)
// then attempt to get and set the user's location
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 } );
}
} else if ( hasBlockedPermissions ) {
if (
placeMode === EXPLORE_V2_PLACE_MODE.UNINITIALIZED
|| placeMode === EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION
) {
// user has explicitly denied location permissions, set place mode to worldwide
dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } );
}
} else if ( placeMode === EXPLORE_V2_PLACE_MODE.UNINITIALIZED ) {
dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_NEEDS_PERMISSION } );
}
} );
useEffect( ( ) => {
if ( hasPermissions !== undefined ) {
onPermissionsResolved( );
}
}, [hasPermissions, hasBlockedPermissions] );
return (
const ExploreV2Container = ( ) => (
<ExploreV2Provider>
<ExploreStackNavigator />
);
};
const ExploreV2Container = ( ) => {
const {
hasPermissions,
hasBlockedPermissions,
renderPermissionsGate,
requestPermissions,
} = useLocationPermission( );
return (
<ExploreV2Provider requestLocationPermissions={requestPermissions}>
<ExploreV2WithProvider
hasPermissions={hasPermissions}
hasBlockedPermissions={hasBlockedPermissions}
/>
{renderPermissionsGate( undefined )}
</ExploreV2Provider>
);
};
</ExploreV2Provider>
);
export default ExploreV2Container;

View File

@@ -10,7 +10,6 @@ import {
Pressable, ScrollView, Text, View,
} from "components/styledComponents";
import {
defaultExploreV2Location,
EXPLORE_V2_ACTION,
EXPLORE_V2_PLACE_MODE,
useExploreV2,
@@ -133,20 +132,6 @@ const ExploreV2DebugSheet = ( ) => {
? state.location.place.id
: null;
const handleNearby = async () => {
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 } );
}
};
const onClose = () => setVisible( false );
return (
@@ -236,9 +221,9 @@ const ExploreV2DebugSheet = ( ) => {
onPress={() => dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } )}
/>
<DebugButton
label="Nearby (re-fetch)"
label="Nearby"
active={placeMode === EXPLORE_V2_PLACE_MODE.NEARBY}
onPress={handleNearby}
onPress={() => dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_NEARBY } )}
/>
{PLACES.map( place => (
<DebugButton
@@ -269,7 +254,7 @@ const ExploreV2DebugSheet = ( ) => {
<Section title="Reset">
<DebugButton
label="RESET (back to UNINITIALIZED)"
label="RESET (back to NEARBY)"
onPress={() => dispatch( { type: EXPLORE_V2_ACTION.RESET } )}
/>
</Section>

View File

@@ -83,8 +83,14 @@ const DefaultSearchOptions = ( { onSelectSubject }: Props ) => {
accessibilityRole="button"
accessibilityLabel={t( "Species-I-havent-observed" )}
className={ROW_CLASSES}
// TODO MOB-1345
onPress={( ) => undefined}
onPress={( ) => onSelectSubject( {
type: "unobserved",
user: {
id: currentUser.id,
login: currentUser.login,
icon_url: currentUser.icon_url,
},
} )}
testID="DefaultSearchOptions.unobserved"
>
<Body1>{t( "Species-I-havent-observed" )}</Body1>

View File

@@ -32,6 +32,8 @@ function subjectLabel( subject: ExploreV2Subject | null, t: TFunction ): string
return subject.user.login;
case "project":
return subject.project.title;
case "unobserved":
return t( "Unobserved" );
default:
return t( "All-organisms" );
}
@@ -42,7 +44,6 @@ function locationLabel( location: ExploreV2LocationState, t: TFunction ): string
case EXPLORE_V2_PLACE_MODE.WORLDWIDE:
return t( "Worldwide" );
case EXPLORE_V2_PLACE_MODE.NEARBY:
case EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION:
return t( "Nearby" );
case EXPLORE_V2_PLACE_MODE.PLACE:
return location.place.display_name || "";
@@ -96,66 +97,117 @@ const SubjectThumbnail = ( { subject }: { subject: ExploreV2Subject } ) => {
}
};
const LocationSubtitle = ( { place }: { place: string } ) => {
if ( !place ) { return null; }
return (
<View className="flex-row items-center pt-[5px]">
<INatIcon name="location" size={15} />
<Body3
maxFontSizeMultiplier={1.5}
className="ml-[5px]"
numberOfLines={1}
ellipsizeMode="tail"
>
{place}
</Body3>
</View>
);
};
const TitleHeader = ( {
title,
place,
testID,
}: {
title: string;
place?: string;
testID?: string;
} ) => (
<View className="flex-1 mr-5 pl-2" testID={testID}>
<Heading2 numberOfLines={1} ellipsizeMode="tail">
{title}
</Heading2>
{place
? <LocationSubtitle place={place} />
: null}
</View>
);
const SubjectHeader = ( {
subject,
label,
place,
prefersCommonNames,
scientificNameFirst,
}: {
subject: ExploreV2Subject;
label: string;
place: string;
prefersCommonNames?: boolean;
scientificNameFirst?: boolean;
} ) => (
<View
className="flex-1 flex-row items-center mr-5"
testID="ExploreV2Header.subject"
>
<SubjectThumbnail subject={subject} />
<View className="flex-1 ml-[10px]">
{subject.type === "taxon"
? (
<DisplayTaxonName
taxon={subject.taxon}
showOneNameOnly
prefersCommonNames={prefersCommonNames}
scientificNameFirst={scientificNameFirst}
/>
)
: (
<Body1 numberOfLines={1} ellipsizeMode="tail">
{label}
</Body1>
)}
<LocationSubtitle place={place} />
</View>
</View>
);
const ExploreV2Header = ( ) => {
const { t } = useTranslation( );
const { state } = useExploreV2( );
const currentUser = useCurrentUser( );
const navigation = useNavigation<ExploreStackScreenProps<"ExploreResults">["navigation"]>( );
const subject = subjectLabel( state.subject, t );
const { subject } = state;
const place = locationLabel( state.location, t );
let headerContent;
if ( subject && subject.type !== "unobserved" ) {
headerContent = (
<SubjectHeader
subject={subject}
label={subjectLabel( subject, t )}
place={place}
prefersCommonNames={currentUser?.prefers_common_names}
scientificNameFirst={currentUser?.prefers_scientific_name_first}
/>
);
} else if ( subject?.type === "unobserved" ) {
headerContent = (
<TitleHeader
title={t( "Unobserved" )}
place={place}
testID="ExploreV2Header.unobserved"
/>
);
} else {
headerContent = <TitleHeader title={place} />;
}
return (
<View className="bg-white" testID="ExploreV2Header">
<View className="p-4 flex-row items-center">
<BackButton />
{state.subject
? (
<View
className="flex-1 flex-row items-center mr-5"
testID="ExploreV2Header.subject"
>
<SubjectThumbnail subject={state.subject} />
<View className="flex-1 ml-[10px]">
{state.subject.type === "taxon"
? (
<DisplayTaxonName
taxon={state.subject.taxon}
showOneNameOnly
prefersCommonNames={currentUser?.prefers_common_names}
scientificNameFirst={currentUser?.prefers_scientific_name_first}
/>
)
: (
<Body1 numberOfLines={1} ellipsizeMode="tail">
{subject}
</Body1>
)}
{place
? (
<View className="flex-row items-center pt-[5px]">
<INatIcon name="location" size={15} />
<Body3
maxFontSizeMultiplier={1.5}
className="ml-[5px]"
numberOfLines={1}
ellipsizeMode="tail"
>
{place}
</Body3>
</View>
)
: null}
</View>
</View>
)
: (
<View className="flex-1 mr-5 pl-2">
<Heading2 numberOfLines={1} ellipsizeMode="tail">
{place}
</Heading2>
</View>
)}
{headerContent}
<ContainedSquareButton
accessibilityHint={t( "Opens-search-interface" )}
accessibilityLabel={t( "Search" )}

View File

@@ -14,6 +14,7 @@ export interface ExploreV2QueryParams {
taxon_id?: number;
user_id?: number;
project_id?: number;
unobserved_by_user_id?: number;
lat?: number;
lng?: number;
radius?: number;
@@ -21,8 +22,15 @@ export interface ExploreV2QueryParams {
verifiable?: boolean;
}
export interface NearbyCoords {
lat: number;
lng: number;
radius: number;
}
const buildExploreV2QueryParams = (
state: ExploreV2State,
nearbyCoords?: NearbyCoords,
): ExploreV2QueryParams => {
const params: ExploreV2QueryParams = {
per_page: PER_PAGE,
@@ -41,6 +49,9 @@ const buildExploreV2QueryParams = (
case "project":
params.project_id = state.subject.project.id;
break;
case "unobserved":
params.unobserved_by_user_id = state.subject.user.id;
break;
default:
break;
}
@@ -48,16 +59,16 @@ const buildExploreV2QueryParams = (
const { location } = state;
switch ( location.placeMode ) {
case EXPLORE_V2_PLACE_MODE.NEARBY:
params.lat = location.lat;
params.lng = location.lng;
params.radius = location.radius;
if ( nearbyCoords ) {
params.lat = nearbyCoords.lat;
params.lng = nearbyCoords.lng;
params.radius = nearbyCoords.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:
case EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION:
break;
default: {
// Exhaustiveness check: ts fails if a new placeMode is added without a case.

View File

@@ -1,5 +1,6 @@
import type { UniversalSearchResultItem }
from "components/Explore/ExploreV2/hooks/useUniversalSearch";
import type { TFunction } from "i18next";
import type { ExploreV2Subject } from "providers/ExploreV2Context";
import { log } from "sharedHelpers/logger";
import { generateTaxonPieces } from "sharedHelpers/taxon";
@@ -54,6 +55,7 @@ export const resultToSubject = ( result: UniversalSearchResultItem ): ExploreV2S
export const subjectToText = (
subject: ExploreV2Subject,
commonNameIsPrimary: boolean,
t: TFunction,
): string => {
switch ( subject.type ) {
case "user":
@@ -64,6 +66,8 @@ export const subjectToText = (
return ( commonNameIsPrimary && subject.taxon.preferred_common_name )
? generateTaxonPieces( subject.taxon ).commonName ?? subject.taxon.name
: subject.taxon.name;
case "unobserved":
return t( "Species-I-havent-observed" );
default:
logger.error( `subjectToText: Unknown explore
subject type: ${( subject as { type: string } ).type}` );

View File

@@ -1,4 +1,5 @@
import { useNetInfo } from "@react-native-community/netinfo";
import { useFocusEffect } from "@react-navigation/native";
import { OBSERVATIONS_TAB } from "appConstants/tabs";
import ExploreV2Header
from "components/Explore/ExploreV2/components/ExploreV2Header";
@@ -6,6 +7,8 @@ import ExploreV2Tabs
from "components/Explore/ExploreV2/components/ExploreV2Tabs";
import ExploreV2DebugSheet
from "components/Explore/ExploreV2/ExploreV2DebugSheet";
import type { NearbyCoords }
from "components/Explore/ExploreV2/helpers/buildQueryParams";
import buildExploreV2QueryParams
from "components/Explore/ExploreV2/helpers/buildQueryParams";
import ExploreV2SpeciesView
@@ -23,16 +26,21 @@ import {
import SortButton from "components/SharedComponents/Buttons/SortButton";
import { View } from "components/styledComponents";
import { EXPLORE_V2_ACTION, EXPLORE_V2_PLACE_MODE, useExploreV2 } from "providers/ExploreV2Context";
import React, { useMemo, useState } from "react";
import React, { useCallback, useMemo, useState } from "react";
import type { OBSERVATIONS_SORT } from "sharedHelpers/observationsSort";
import {
OBSERVATIONS_SORT_OPTIONS,
useObservationsSortLabels,
} from "sharedHelpers/observationsSort";
import { useTranslation } from "sharedHooks";
import useLocationPermission from "sharedHooks/useLocationPermission";
import useSpeciesCount from "sharedHooks/useSpeciesCount";
import useStoredLayout from "sharedHooks/useStoredLayout";
// 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";
interface SortOption {
label: string;
text: string;
@@ -40,7 +48,13 @@ interface SortOption {
}
const ExploreResults = ( ) => {
const { dispatch, state, requestLocationPermissions } = useExploreV2( );
const { dispatch, state } = useExploreV2( );
const {
hasPermissions,
hasBlockedPermissions,
renderPermissionsGate,
requestPermissions,
} = useLocationPermission( );
const { isConnected } = useNetInfo( );
const { t } = useTranslation( );
const [showSortSheet, setShowSortSheet] = useState( false );
@@ -60,13 +74,36 @@ const ExploreResults = ( ) => {
{} as Record<OBSERVATIONS_SORT, SortOption>,
);
const queryParams = useMemo(
( ) => buildExploreV2QueryParams( state ),
[state],
);
const isNearby = state.location.placeMode === EXPLORE_V2_PLACE_MODE.NEARBY;
const [nearbyCoords, setNearbyCoords] = useState<NearbyCoords | undefined>( undefined );
const canFetch = state.location.placeMode !== EXPLORE_V2_PLACE_MODE.UNINITIALIZED
&& state.location.placeMode !== EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION;
useFocusEffect( useCallback( ( ) => {
let cancelled = false;
if ( isNearby && hasBlockedPermissions ) {
// perms blocked: fall back to worldwide
dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } );
} else if ( isNearby && hasPermissions === true && nearbyCoords === undefined ) {
fetchCoarseUserLocation( ).then( location => {
if ( cancelled ) return;
if ( typeof location?.latitude === "number" ) {
setNearbyCoords( { lat: location.latitude, lng: location.longitude, radius: 1 } );
} else {
// Perms granted but no fix — fall back to worldwide.
dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } );
}
} );
}
return ( ) => { cancelled = true; };
}, [isNearby, hasPermissions, hasBlockedPermissions, nearbyCoords, dispatch] ) );
const needsPermission = isNearby && hasPermissions === false && !hasBlockedPermissions;
const nearbyResolved = !isNearby || nearbyCoords !== undefined;
const canFetch = !needsPermission && nearbyResolved;
const queryParams = useMemo(
( ) => buildExploreV2QueryParams( state, nearbyCoords ),
[state, nearbyCoords],
);
const {
fetchNextPage,
@@ -99,7 +136,7 @@ const ExploreResults = ( ) => {
text={t( "ALLOW-LOCATION-ACCESS" )}
accessibilityHint={t( "Opens-location-permission-prompt" )}
level="focus"
onPress={requestLocationPermissions}
onPress={requestPermissions}
/>
</View>
);
@@ -109,10 +146,14 @@ const ExploreResults = ( ) => {
<View className="flex-1 overflow-hidden">
<ExploreV2Header />
<ExploreV2Tabs
observationsCount={totalResults}
speciesCount={speciesCount}
observationsCount={canFetch
? totalResults
: undefined}
speciesCount={canFetch
? speciesCount
: undefined}
/>
{state.location.placeMode === EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION
{needsPermission
? renderPermissionPrompt( )
: ( // more tabs to come in MOB-1347
<>
@@ -135,7 +176,7 @@ const ExploreResults = ( ) => {
hideObsUploadStatus={layout !== "list"}
obsListKey="ExploreV2Observations"
onEndReached={fetchNextPage}
showNoResults={!canFetch || totalResults === 0}
showNoResults={canFetch && totalResults === 0}
testID="ExploreV2ObservationsList"
/>
<ObservationsViewBar
@@ -177,6 +218,7 @@ const ExploreResults = ( ) => {
onPressClose={() => setShowSortSheet( false )}
/>
)}
{renderPermissionsGate( {} )}
</ViewWrapper>
);
};

View File

@@ -31,9 +31,7 @@ import {
import type { ExploreStackScreenProps } from "navigation/types";
import type { ExploreV2Subject, Place } from "providers/ExploreV2Context";
import {
defaultExploreV2Location,
EXPLORE_V2_ACTION,
EXPLORE_V2_PLACE_MODE,
useExploreV2,
} from "providers/ExploreV2Context";
import React, { useCallback, useRef, useState } from "react";
@@ -62,8 +60,7 @@ type SearchResultItem = UniversalSearchResultItem | LocationSearchResultItem;
type SelectedLocation =
| { type: "place"; place: Place }
| { type: "nearby"; lat: number; lng: number; radius: number }
| { type: "nearby-needs-permission" }
| { type: "nearby" }
| { type: "worldwide" };
const resultKey = ( item: SearchResultItem ): string => {
@@ -138,9 +135,9 @@ const UniversalSearch = ( ) => {
const handleSubjectSelect = useCallback( ( subject: ExploreV2Subject ) => {
setSelectedSubject( subject );
commitSubject( subjectToText( subject, commonNameIsPrimary ) );
commitSubject( subjectToText( subject, commonNameIsPrimary, t ) );
locationInputRef.current?.focus( );
}, [commitSubject, commonNameIsPrimary] );
}, [commitSubject, commonNameIsPrimary, t] );
const handleLocationSelect = useCallback( ( place: LocationSearchResultItem ) => {
setSelectedLocation( {
@@ -158,21 +155,8 @@ const UniversalSearch = ( ) => {
Keyboard.dismiss( );
}, [commitLocation, t] );
const handleSelectNearby = useCallback( async ( ) => {
const next = await defaultExploreV2Location( );
switch ( next.placeMode ) {
case EXPLORE_V2_PLACE_MODE.NEARBY:
setSelectedLocation( {
type: "nearby", lat: next.lat, lng: next.lng, radius: next.radius,
} );
break;
case EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION:
setSelectedLocation( { type: "nearby-needs-permission" } );
break;
default:
// Permission granted but no fix available: fall back to worldwide.
setSelectedLocation( { type: "worldwide" } );
}
const handleSelectNearby = useCallback( ( ) => {
setSelectedLocation( { type: "nearby" } );
commitLocation( t( "Nearby" ) );
Keyboard.dismiss( );
}, [commitLocation, t] );
@@ -202,15 +186,7 @@ const UniversalSearch = ( ) => {
} );
break;
case "nearby":
dispatch( {
type: EXPLORE_V2_ACTION.SET_LOCATION_NEARBY,
lat: selectedLocation.lat,
lng: selectedLocation.lng,
radius: selectedLocation.radius,
} );
break;
case "nearby-needs-permission":
dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_NEEDS_PERMISSION } );
dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_NEARBY } );
break;
default:
dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } );
@@ -292,6 +268,7 @@ const UniversalSearch = ( ) => {
<INatIcon name="magnifying-glass" size={15} color={colors.darkGray} />
<TextInput
accessibilityLabel={t( "Search-for-species-user-or-project" )}
autoCorrect={false}
autoFocus
className="flex-1 ml-2 text-md font-Lato-Regular"
numberOfLines={1}
@@ -299,6 +276,7 @@ const UniversalSearch = ( ) => {
onFocus={handleSubjectFocus}
placeholder={t( "Search-for-species-user-or-project" )}
placeholderTextColor={colors.mediumGray}
spellCheck={false}
testID="UniversalSearch.subjectInput"
value={subjectText}
/>
@@ -307,6 +285,7 @@ const UniversalSearch = ( ) => {
<INatIcon name="map-marker-outline" size={15} color={colors.darkGray} />
<TextInput
accessibilityLabel={t( "Search-for-a-location" )}
autoCorrect={false}
className="flex-1 ml-2 text-md font-Lato-Regular"
numberOfLines={1}
onChangeText={onChangeLocationText}
@@ -314,6 +293,7 @@ const UniversalSearch = ( ) => {
placeholder={t( "Search-for-a-location" )}
placeholderTextColor={colors.mediumGray}
ref={locationInputRef}
spellCheck={false}
testID="UniversalSearch.locationInput"
value={locationText}
/>

View File

@@ -17,13 +17,11 @@ import React, {
useRef,
useState,
} from "react";
import { useCurrentUser, useFeatureFlag } from "sharedHooks";
import { useCurrentUser } from "sharedHooks";
import useLocationPermission from "sharedHooks/useLocationPermission";
import { FeatureFlag } from "stores/createFeatureFlagSlice";
import useStore from "stores/useStore";
import Explore from "./Explore";
import ExploreV2 from "./ExploreV2";
import mapParamsToAPI from "./helpers/mapParamsToAPI";
import useExploreHeaderCount from "./hooks/useExploreHeaderCount";
@@ -31,7 +29,6 @@ const RootExploreContainerWithContext = ( ): Node => {
const navigation = useNavigation( );
const { isConnected } = useNetInfo( );
const currentUser = useCurrentUser( );
const exploreV2Enabled = useFeatureFlag( FeatureFlag.ExploreV2Enabled );
const rootExploreView = useStore( state => state.rootExploreView );
const setRootExploreView = useStore( state => state.setRootExploreView );
const rootStoredParams = useStore( state => state.rootStoredParams );
@@ -214,48 +211,32 @@ const RootExploreContainerWithContext = ( ): Node => {
return (
<>
{!exploreV2Enabled
? (
<Explore
canFetch={canFetch}
closeFiltersModal={closeFiltersModal}
count={count}
hideBackButton
filterByIconicTaxonUnknown={
() => dispatch( { type: EXPLORE_ACTION.FILTER_BY_ICONIC_TAXON_UNKNOWN } )
}
currentExploreView={rootExploreView}
setCurrentExploreView={setRootExploreView}
isConnected={isConnected}
isFetchingHeaderCount={isFetchingHeaderCount}
handleUpdateCount={handleUpdateCount}
openFiltersModal={openFiltersModal}
queryParams={queryParams}
showFiltersModal={showFiltersModal}
updateTaxon={taxon => dispatch( { type: EXPLORE_ACTION.CHANGE_TAXON, taxon } )}
updateLocation={updateLocation}
updateUser={updateUser}
updateProject={updateProject}
placeMode={state.placeMode}
hasLocationPermissions={hasLocationPermissions}
requestLocationPermissions={requestLocationPermissions}
startFetching={startFetching}
renderLocationPermissionsGate={renderPermissionsGate}
/>
)
: (
<ExploreV2
canFetch={canFetch}
currentExploreView={rootExploreView}
handleUpdateCount={handleUpdateCount}
hasLocationPermissions={hasLocationPermissions}
isConnected={isConnected}
placeMode={state.placeMode}
queryParams={queryParams}
renderLocationPermissionsGate={renderPermissionsGate}
requestLocationPermissions={requestLocationPermissions}
/>
)}
<Explore
canFetch={canFetch}
closeFiltersModal={closeFiltersModal}
count={count}
hideBackButton
filterByIconicTaxonUnknown={
() => dispatch( { type: EXPLORE_ACTION.FILTER_BY_ICONIC_TAXON_UNKNOWN } )
}
currentExploreView={rootExploreView}
setCurrentExploreView={setRootExploreView}
isConnected={isConnected}
isFetchingHeaderCount={isFetchingHeaderCount}
handleUpdateCount={handleUpdateCount}
openFiltersModal={openFiltersModal}
queryParams={queryParams}
showFiltersModal={showFiltersModal}
updateTaxon={taxon => dispatch( { type: EXPLORE_ACTION.CHANGE_TAXON, taxon } )}
updateLocation={updateLocation}
updateUser={updateUser}
updateProject={updateProject}
placeMode={state.placeMode}
hasLocationPermissions={hasLocationPermissions}
requestLocationPermissions={requestLocationPermissions}
startFetching={startFetching}
renderLocationPermissionsGate={renderPermissionsGate}
/>
{renderPermissionsGate( {
onPermissionGranted: async ( ) => {
await updateLocation( "nearby" );

View File

@@ -1,6 +1,6 @@
import { useNavigation } from "@react-navigation/native";
import { POST_FOR_USER_FIELDS } from "api/fields";
import { fetchUserPosts } from "api/posts";
import { fetchBlogPosts } from "api/posts";
import { ScreenShell } from "components/SharedComponents/ViewWrapper";
import type { TabStackScreenProps } from "navigation/types";
import React, {
@@ -18,7 +18,7 @@ const Blog = ( ) => {
const navigation = useNavigation<TabStackScreenProps<"Journal">["navigation"]>( );
const { t } = useTranslation( );
const queryKey = ["fetchUserPosts"];
const queryKey = ["fetchBlogPosts"];
const queryParams = {
fields: POST_FOR_USER_FIELDS,
};
@@ -28,7 +28,7 @@ const Blog = ( ) => {
fetchNextPage,
isFetchingNextPage,
totalResults: totalPosts,
} = useInfiniteScroll( queryKey, fetchUserPosts, queryParams, {
} = useInfiniteScroll( queryKey, fetchBlogPosts, queryParams, {
enabled: true,
} );

View File

@@ -4,11 +4,12 @@ import React from "react";
import Blog from "./Blog";
import ProjectPosts from "./ProjectPosts";
import UserPosts from "./UserPosts";
const Journal = ( ) => {
const { params } = useRoute<TabStackScreenProps<"Journal">["route"]>( );
const {
journalPostsCount, projectIcon, projectId, projectTitle, userLogin,
projectIcon, projectId, projectTitle, userIcon, userId, userLogin,
} = params || {};
if ( projectId ) {
@@ -21,10 +22,14 @@ const Journal = ( ) => {
);
}
// TODO: posts for one user
if ( userLogin ) {
console.log( journalPostsCount );
return null;
if ( userId ) {
return (
<UserPosts
userIcon={userIcon}
userId={userId}
userLogin={userLogin}
/>
);
}
return (

View File

@@ -16,11 +16,11 @@ import PostList from "./PostList";
interface Props {
projectIcon?: string;
projectId?: number;
projectId: number;
projectTitle?: string;
}
const PostsForProjects = ( {
const ProjectPosts = ( {
projectIcon,
projectId,
projectTitle,
@@ -81,4 +81,4 @@ const PostsForProjects = ( {
);
};
export default PostsForProjects;
export default ProjectPosts;

View File

@@ -0,0 +1,84 @@
import { useNavigation } from "@react-navigation/native";
import { POST_FOR_PROJECT_FIELDS } from "api/fields";
import { fetchUserPosts } from "api/posts";
import { ScreenShell } from "components/SharedComponents/ViewWrapper";
import type { TabStackScreenProps } from "navigation/types";
import React, {
useEffect,
useMemo,
} from "react";
import {
useInfiniteScroll,
useTranslation,
} from "sharedHooks";
import PostList from "./PostList";
interface Props {
userIcon?: string;
userId: number;
userLogin?: string;
}
const UserPosts = ( {
userIcon,
userId,
userLogin,
}: Props ) => {
const navigation
= useNavigation<TabStackScreenProps<"Journal">["navigation"]>();
const { t } = useTranslation();
const queryKey = ["fetchUserPosts", userId];
const queryParams = {
id: userId,
fields: POST_FOR_PROJECT_FIELDS,
};
const {
data: userPosts,
fetchNextPage,
isFetchingNextPage,
totalResults: totalPosts,
} = useInfiniteScroll( queryKey, fetchUserPosts, queryParams, {
enabled: !!userId,
} );
const headerOptions = useMemo(
() => ( {
headerTitle: userLogin,
headerSubtitle: t( "X-JOURNAL_POSTS", {
count: totalPosts || 0,
} ),
} ),
[totalPosts, t, userLogin],
);
useEffect( () => {
navigation.setOptions( headerOptions );
}, [headerOptions, navigation] );
const enrichedPosts = useMemo( () => {
if ( !userPosts ) return null;
return userPosts?.map( p => ( {
...p,
parent: {
id: userId,
icon_url: userIcon,
},
} ) );
}, [userIcon, userId, userPosts] );
return (
<ScreenShell>
<PostList
posts={enrichedPosts}
fetchNextPage={fetchNextPage}
isFetchingNextPage={isFetchingNextPage}
/>
</ScreenShell>
);
};
export default UserPosts;

View File

@@ -1,185 +0,0 @@
/* eslint-disable i18next/no-literal-string */
import classnames from "classnames";
import useMyObservationsQuery from "components/MyObservations/hooks/useMyObservationsQuery";
import { INatIconButton } from "components/SharedComponents";
import Modal from "components/SharedComponents/Modal";
import Body3 from "components/SharedComponents/Typography/Body3";
import Heading4 from "components/SharedComponents/Typography/Heading4";
import {
Image, Pressable, ScrollView, Text, View,
} from "components/styledComponents";
import { RealmContext } from "providers/contexts";
import {
MY_OBSERVATIONS_ACTION,
useMyObservations,
} from "providers/MyObservationsContext";
import React, { useState } from "react";
import type { RealmObservation } from "realmModels/types";
import { OBSERVATIONS_SORT, OBSERVATIONS_SORT_OPTIONS } from "sharedHelpers/observationsSort";
import useDebugMode from "sharedHooks/useDebugMode";
const { useObject } = RealmContext;
const SORT_LABELS: Record<OBSERVATIONS_SORT, string> = {
[OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST]: "Uploaded ↓",
[OBSERVATIONS_SORT.DATE_UPLOADED_OLDEST]: "Uploaded ↑",
[OBSERVATIONS_SORT.DATE_OBSERVED_NEWEST]: "Observed ↓",
[OBSERVATIONS_SORT.DATE_OBSERVED_OLDEST]: "Observed ↑",
};
const SCROLL_CONTENT = { paddingBottom: 32 };
interface DebugButtonProps {
label: string;
onPress: ( ) => void;
active?: boolean;
}
const DebugButton = ( { label, onPress, active }: DebugButtonProps ) => (
<Pressable
accessibilityRole="button"
onPress={onPress}
className={`px-3 py-2 mr-2 mb-2 rounded ${
active
? "bg-inatGreen"
: "bg-darkGray"
}`}
>
<Text className="text-white text-xs">{label}</Text>
</Pressable>
);
interface DebugRealmObservation extends RealmObservation {
id?: number;
}
interface ObservationRowProps {
uuid: string;
index: number;
}
const ObservationRow = ( { uuid, index }: ObservationRowProps ) => {
const observation = useObject<DebugRealmObservation>( "Observation", uuid );
const thumbnailUrl = observation?.observationPhotos?.[0]?.photo?.url;
const name = observation?.taxon?.preferredCommonName
|| observation?.taxon?.name
|| "(no taxon)";
const uploadedAt = observation?._created_at
? observation._created_at.toLocaleDateString( )
: "—";
const observedOn = observation?.observed_on
? new Date( observation.observed_on ).toLocaleDateString( )
: "—";
return (
<View className="flex-row items-center mb-3 pb-3 border-b border-lightGray">
<Text className="w-6 text-xs">{index + 1}</Text>
{thumbnailUrl
? <Image source={{ uri: thumbnailUrl }} className="w-12 h-12 rounded mr-2" />
: <View className="w-12 h-12 rounded mr-2 bg-lightGray" />}
<View className="flex-1">
<Text numberOfLines={1} className="font-bold">{name}</Text>
<Text className="text-xs text-darkGray">
{`id: ${observation?.id ?? "—"} · uuid: ${uuid}`}
</Text>
<Text className="text-xs text-darkGray">{`uploaded: ${uploadedAt}`}</Text>
<Text className="text-xs text-darkGray">{`observed: ${observedOn}`}</Text>
</View>
</View>
);
};
interface DebugSheetContentProps {
onClose: ( ) => void;
}
// Split out from the sheet shell so useObservationsQuery (and the network
// fetch it can trigger) only runs while the sheet is actually open
const DebugSheetContent = ( { onClose }: DebugSheetContentProps ) => {
const { state, dispatch } = useMyObservations( );
const {
observationIds,
isServerAuthoritative,
isLoading,
error,
refetch,
} = useMyObservationsQuery( );
return (
<View className="bg-white rounded-t-2xl h-[85%]">
<View
className={classnames(
"px-4 pt-4 pb-2 flex-row items-center justify-between",
"border-b border-lightGray",
)}
>
<Heading4>MyObs Query Debug</Heading4>
<Pressable accessibilityRole="button" onPress={onClose}>
<Text className="text-base text-inatGreen">Close</Text>
</Pressable>
</View>
<ScrollView
className="flex-1 px-4 py-3"
contentContainerStyle={SCROLL_CONTENT}
>
<View className="flex-row flex-wrap mb-3">
{OBSERVATIONS_SORT_OPTIONS.map( sort => (
<DebugButton
key={sort}
label={SORT_LABELS[sort]}
active={state.observationsSort === sort}
onPress={( ) => dispatch( {
type: MY_OBSERVATIONS_ACTION.SET_OBSERVATIONS_SORT,
observationsSort: sort,
} )}
/>
) )}
<DebugButton label="Refresh" onPress={( ) => refetch( )} />
</View>
<Body3 className="mb-3">
{`Server authoritative: ${isServerAuthoritative
? "yes"
: "no"}`}
</Body3>
{isLoading && <Body3 className="mb-2">Loading</Body3>}
{!!error && <Body3 className="mb-2">{`Error: ${error.message}`}</Body3>}
<Body3 className="mb-3">{`Showing ${observationIds.length} observations`}</Body3>
{observationIds.map( ( { uuid }, index ) => (
<ObservationRow key={uuid} uuid={uuid} index={index} />
) )}
</ScrollView>
</View>
);
};
const MyObsServerOrderedDebugSheet = ( ) => {
const { isDebug } = useDebugMode( );
const [visible, setVisible] = useState( false );
if ( !isDebug ) return null;
const onClose = ( ) => setVisible( false );
return (
<>
<INatIconButton
icon="triangle-exclamation"
className="absolute top-64 right-5 rounded-full bg-deepPink"
color="white"
size={27}
accessibilityLabel="MyObs server order diagnostics"
onPress={( ) => setVisible( true )}
/>
<Modal
showModal={visible}
closeModal={onClose}
disableSwipeDirection
modal={visible
? <DebugSheetContent onClose={onClose} />
: null}
/>
</>
);
};
export default MyObsServerOrderedDebugSheet;

View File

@@ -37,8 +37,10 @@ import {
useStoredLayout,
useTranslation,
} from "sharedHooks";
import useFeatureFlag from "sharedHooks/useFeatureFlag";
import useLocalObservationIds from "sharedHooks/useLocalObservationIds";
import useObservationCounts from "sharedHooks/useObservationCounts";
import { FeatureFlag } from "stores/createFeatureFlagSlice";
import {
UPLOAD_PENDING,
} from "stores/createUploadObservationsSlice";
@@ -46,6 +48,7 @@ import useStore, { zustandStorage } from "stores/useStore";
import type { SpeciesCount } from "types/sorting";
import FullScreenActivityIndicator from "./FullScreenActivityIndicator";
import useMyObservationsQuery from "./hooks/useMyObservationsQuery";
import useSyncObservations from "./hooks/useSyncObservations";
import useUploadObservations from "./hooks/useUploadObservations";
import MyObservationsEmptySimple from "./MyObservationsEmptySimple";
@@ -53,7 +56,6 @@ import MyObservationsSimple, {
OBSERVATIONS_TAB,
TAXA_TAB,
} from "./MyObservationsSimple";
import MyObsServerOrderedDebugSheet from "./MyObsServerOrderedDebugSheet";
const { useRealm } = RealmContext;
@@ -93,7 +95,28 @@ const MyObservationsResults = ( ) => {
return unsubscribe;
}, [navigation, setJustFinishedSignup] );
const observationIds = useLocalObservationIds();
const localObservationIds = useLocalObservationIds();
const sortMyObservationsEnabled = useFeatureFlag( FeatureFlag.SortMyObservationsEnabled );
const searchMyObservationsEnabled = useFeatureFlag( FeatureFlag.SearchMyObservationsEnabled );
const {
observationIds: queryObservationIds,
isServerAuthoritative,
isLoading: isLoadingFromQuery,
isFetchingNextPage: isFetchingNextPageFromQuery,
fetchNextPage: fetchNextPageFromQuery,
refetch: refetchFromQuery,
} = useMyObservationsQuery( );
// Only use server-ordered result when at least one of the features that needs it is enabled;
// when neither is, we use the plain local list anyway
const myObsQueryEnabled = sortMyObservationsEnabled || searchMyObservationsEnabled;
const useServerOrder = myObsQueryEnabled && isServerAuthoritative;
const observationIds = myObsQueryEnabled
? queryObservationIds
: localObservationIds;
const showSearchEmptyState = searchMyObservationsEnabled
&& !!myObsState.searchedTaxon
&& !isLoadingFromQuery
&& observationIds.length === 0;
const {
numUnuploadedObservations,
numObsMissingBasics,
@@ -244,7 +267,10 @@ const MyObservationsResults = ( ) => {
const handlePullToRefresh = useCallback( async ( ) => {
await syncManually( { skipUploads: true } );
refetchObservationsUpdates( );
}, [syncManually, refetchObservationsUpdates] );
if ( useServerOrder ) {
refetchFromQuery( );
}
}, [syncManually, refetchObservationsUpdates, useServerOrder, refetchFromQuery] );
// Scroll the list to the offset we need to restore, e.g. when you are
// scrolled way down, edit an observation, and return. Entering ObsEdit
@@ -258,6 +284,13 @@ const MyObservationsResults = ( ) => {
myObsOffsetToRestore,
] );
// Scroll to the top whenever the active taxon search changes
useEffect( ( ) => {
if ( listRef.current ) {
listRef.current.scrollToOffset( { offset: 0, animated: true } );
}
}, [myObsState.searchedTaxon?.id] );
// API call fetching obs has completed but results are not yet stored in realm
// for display here
const showLoading = ( totalResultsRemote || 0 ) > 0 && observationIds.length === 0;
@@ -365,6 +398,14 @@ const MyObservationsResults = ( ) => {
const numTotalObservations = totalResultsRemote || observationIds.length;
// Pagination for the rendered list follows whichever source is authoritative:
const isFetchingNextPageForList = useServerOrder
? isFetchingNextPageFromQuery
: isFetchingNextPage;
const handleEndReached = useServerOrder
? fetchNextPageFromQuery
: fetchNextPage;
useEffect( ( ) => {
// persist this number in zustand so a user can see their latest observations count
// even if they're offline
@@ -417,7 +458,7 @@ const MyObservationsResults = ( ) => {
if ( !layout ) { return null; }
if ( observationIds.length === 0 ) {
if ( observationIds.length === 0 && !totalResultsRemote ) {
return showNoResults
? (
<MyObservationsEmptySimple
@@ -432,45 +473,43 @@ const MyObservationsResults = ( ) => {
}
return (
<>
<MyObservationsSimple
activeTab={activeTab}
currentUser={currentUser}
fetchMoreTaxa={fetchMoreTaxa}
fetchFromLastObservation={fetchFromLastObservation}
handleIndividualUploadPress={handleIndividualUploadPress}
handlePullToRefresh={handlePullToRefresh}
handleSyncButtonPress={handleSyncButtonPress}
isConnected={isConnected}
isFetchingNextPage={isFetchingNextPage}
isFetchingTaxa={isFetchingTaxa}
justFinishedSignup={justFinishedSignup}
layout={layout}
listRef={listRef}
loggedInWhileInDefaultMode={loggedInWhileInDefaultMode}
taxaListRef={taxaListRef}
numTotalObservations={numOfUserObservations}
numTotalTaxa={numOfUserSpecies}
numUnuploadedObservations={numUnuploadedObservations}
numObsMissingBasics={numObsMissingBasics}
observationIds={observationIds}
observationsSortOptionId={myObsState.observationsSort}
onEndReached={fetchNextPage}
onListLayout={restoreScrollOffset}
onScroll={onScroll}
openSheet={openSheet}
refetchTaxa={refetchTaxa}
setActiveTab={setActiveTab}
setObservationsSortOptionId={setObservationsSortOptionId}
setOpenSheet={setOpenSheet}
setSpeciesSortOptionId={setSpeciesSortOptionId}
showNoResults={showNoResults}
speciesSortOptionId={myObsState.speciesSort}
taxa={taxa}
toggleLayout={toggleLayout}
/>
<MyObsServerOrderedDebugSheet />
</>
<MyObservationsSimple
activeTab={activeTab}
currentUser={currentUser}
fetchMoreTaxa={fetchMoreTaxa}
fetchFromLastObservation={fetchFromLastObservation}
handleIndividualUploadPress={handleIndividualUploadPress}
handlePullToRefresh={handlePullToRefresh}
handleSyncButtonPress={handleSyncButtonPress}
isConnected={isConnected}
isFetchingNextPage={isFetchingNextPageForList}
isFetchingTaxa={isFetchingTaxa}
justFinishedSignup={justFinishedSignup}
layout={layout}
listRef={listRef}
loggedInWhileInDefaultMode={loggedInWhileInDefaultMode}
taxaListRef={taxaListRef}
numTotalObservations={numOfUserObservations}
numTotalTaxa={numOfUserSpecies}
numUnuploadedObservations={numUnuploadedObservations}
numObsMissingBasics={numObsMissingBasics}
observationIds={observationIds}
observationsSortOptionId={myObsState.observationsSort}
onEndReached={handleEndReached}
onListLayout={restoreScrollOffset}
onScroll={onScroll}
openSheet={openSheet}
refetchTaxa={refetchTaxa}
setActiveTab={setActiveTab}
setObservationsSortOptionId={setObservationsSortOptionId}
setOpenSheet={setOpenSheet}
setSpeciesSortOptionId={setSpeciesSortOptionId}
showNoResults={showNoResults}
showSearchEmptyState={showSearchEmptyState}
speciesSortOptionId={myObsState.speciesSort}
taxa={taxa}
toggleLayout={toggleLayout}
/>
);
};

View File

@@ -50,6 +50,7 @@ import { ACTIVE_SHEET } from "./MyObservationsResults";
import MyObservationsSimpleHeader from "./MyObservationsSimpleHeader";
import PivotCardObsGridItem from "./PivotCardObsGridItem";
import SearchedTaxonBanner from "./Search/SearchedTaxonBanner";
import SearchEmptyState from "./Search/SearchEmptyState";
import SimpleHeader from "./SimpleHeader";
import SimpleTaxonGridItem from "./SimpleTaxonGridItem";
@@ -80,6 +81,7 @@ interface Props {
setOpenSheet: ( value: ACTIVE_SHEET ) => void;
setSpeciesSortOptionId: ( value: SPECIES_SORT ) => void;
showNoResults: boolean;
showSearchEmptyState: boolean;
speciesSortOptionId: SPECIES_SORT;
taxa?: SpeciesCount[];
toggleLayout: ( ) => void;
@@ -138,6 +140,7 @@ const MyObservationsSimple = ( {
setOpenSheet,
setSpeciesSortOptionId,
showNoResults,
showSearchEmptyState,
speciesSortOptionId,
taxa,
toggleLayout,
@@ -360,6 +363,13 @@ const MyObservationsSimple = ( {
}
setObservationsSortOptionId( optionId );
// scroll to the top of the newly sorted list
setTimeout( () => {
if ( listRef?.current ) {
listRef.current.scrollToOffset( { offset: 0, animated: true } );
}
}, 0 );
setOpenSheet( ACTIVE_SHEET.NONE );
};
@@ -407,44 +417,48 @@ const MyObservationsSimple = ( {
<SearchedTaxonBanner />
)}
{ activeTab === OBSERVATIONS_TAB && (
<>
<ObservationsFlashList
data={dataFilledWithEmptyBoxes}
dataCanBeFetched={!!currentUser}
fetchFromLastObservation={fetchFromLastObservation}
handlePullToRefresh={handlePullToRefresh}
handleIndividualUploadPress={handleIndividualUploadPress}
hideLoadingWheel={!isFetchingNextPage}
hideMetadata={isDefaultMode}
hideObsUploadStatus={!currentUser}
hideObsStatus={!currentUser}
isSimpleObsStatus={isDefaultMode}
hideRGLabel={!isDefaultMode || !currentUser}
isFetchingNextPage={isFetchingNextPage}
isConnected={isConnected}
obsListKey="MyObservations"
layout={layout}
onEndReached={onEndReached}
onLayout={onListLayout}
onScroll={onScroll}
ref={listRef}
showObservationsEmptyScreen
showNoResults={showNoResults}
testID="MyObservationsAnimatedList"
listHeaderContent={observationsHeader}
/>
<ObservationsViewBar
hideMap
layout={layout}
updateObservationsView={toggleLayout}
/>
{sortMyObservationsEnabled && (
<SortButton
onPress={() => setOpenSheet( ACTIVE_SHEET.SORT )}
accessibilityLabel={t( "Change-observations-sort-order" )}
/>
)}
</>
showSearchEmptyState
? <SearchEmptyState />
: (
<>
<ObservationsFlashList
data={dataFilledWithEmptyBoxes}
dataCanBeFetched={!!currentUser}
fetchFromLastObservation={fetchFromLastObservation}
handlePullToRefresh={handlePullToRefresh}
handleIndividualUploadPress={handleIndividualUploadPress}
hideLoadingWheel={!isFetchingNextPage}
hideMetadata={isDefaultMode}
hideObsUploadStatus={!currentUser}
hideObsStatus={!currentUser}
isSimpleObsStatus={isDefaultMode}
hideRGLabel={!isDefaultMode || !currentUser}
isFetchingNextPage={isFetchingNextPage}
isConnected={isConnected}
obsListKey="MyObservations"
layout={layout}
onEndReached={onEndReached}
onLayout={onListLayout}
onScroll={onScroll}
ref={listRef}
showObservationsEmptyScreen
showNoResults={showNoResults}
testID="MyObservationsAnimatedList"
listHeaderContent={observationsHeader}
/>
<ObservationsViewBar
hideMap
layout={layout}
updateObservationsView={toggleLayout}
/>
{sortMyObservationsEnabled && (
<SortButton
onPress={() => setOpenSheet( ACTIVE_SHEET.SORT )}
accessibilityLabel={t( "Change-observations-sort-order" )}
/>
)}
</>
)
) }
{ ( activeTab === TAXA_TAB && taxa.length > 0 ) && (
<>

View File

@@ -1,6 +1,3 @@
// TODO: This component is intentionally not rendered anywhere yet.
// This is the empty state for the Search My Observations feature.
import {
Body1,
Button,

View File

@@ -1,3 +1,4 @@
import { useNetInfo } from "@react-native-community/netinfo";
import { useNavigation } from "@react-navigation/native";
import type { ApiTaxon } from "api/types";
import {
@@ -11,6 +12,7 @@ import {
useMyObservations,
} from "providers/MyObservationsContext";
import React, { useCallback, useState } from "react";
import { Alert } from "react-native";
import type { RealmTaxon } from "realmModels/types";
import { taxonDisplayName } from "sharedHelpers/taxon";
import { useCurrentUser, useTranslation } from "sharedHooks";
@@ -22,6 +24,7 @@ const SearchMyObservationsTaxon = ( ) => {
const { state, dispatch } = useMyObservations( );
const { searchedTaxon } = state;
const currentUser = useCurrentUser( );
const { isConnected } = useNetInfo( );
const [taxonQuery, setTaxonQuery] = useState( ( ) => (
searchedTaxon
@@ -35,6 +38,13 @@ const SearchMyObservationsTaxon = ( ) => {
const onTaxonSelected = useCallback( ( newTaxon: ApiTaxon | null ) => {
if ( newTaxon && typeof newTaxon.id === "number" && newTaxon.name ) {
if ( currentUser && !isConnected ) {
Alert.alert(
t( "You-are-offline" ),
t( "Please-try-again-when-you-are-online" ),
);
return;
}
// useTaxonSearch can return either ApiTaxon-shaped or RealmTaxon-shaped
// taxa depending on the source, so we have to check for both here.
// TODO: normalize taxa at ingest.
@@ -56,7 +66,7 @@ const SearchMyObservationsTaxon = ( ) => {
dispatch( { type: MY_OBSERVATIONS_ACTION.CLEAR_TAXON_SEARCH } );
}
closeScreen( );
}, [closeScreen, dispatch] );
}, [closeScreen, currentUser, dispatch, isConnected, t] );
const resetSearch = useCallback( ( ) => {
setTaxonQuery( "" );

View File

@@ -2,6 +2,7 @@ import { RealmContext } from "providers/contexts";
import { useMyObservations } from "providers/MyObservationsContext";
import { useMemo } from "react";
import { OBSERVATIONS_SORT } from "sharedHelpers/observationsSort";
import { useCurrentUser } from "sharedHooks";
import useLocalObservationIds from "sharedHooks/useLocalObservationIds";
import useServerOrderedObservations from "./useServerOrderedObservations";
@@ -9,34 +10,51 @@ import useServerOrderedObservations from "./useServerOrderedObservations";
const { useQuery } = RealmContext;
const NOOP_REFETCH = ( ) => undefined;
const NOOP_FETCH_NEXT_PAGE = ( ) => undefined;
interface UseMyObservationsQueryResult {
observationIds: { uuid: string }[];
isServerAuthoritative: boolean;
isLoading: boolean;
isFetchingNextPage: boolean;
error: Error | null;
refetch: ( ) => void;
fetchNextPage: ( ) => void;
}
// We want to preserve offline behavior for the default sort (created at, desc) so a user can see
// and interact with their obs offline. This hook uses selected sort to determine whether Realm or
// the server should be the authoritative source of a user's observations (unsynced obs
// are always merged in at the top regardless of source).
// and interact with their obs offline. This hook uses selected sort and/or an active taxon search
// to determine whether Realm or the server should be the authoritative source of a user's
// observations (unsynced obs are always merged in at the top regardless of source).
//
// Logged-out users can never have server-ordered observations, since they can't upload until
// they log in, so a non-default sort is applied to their local observations instead.
const useMyObservationsQuery = ( ): UseMyObservationsQueryResult => {
const { state } = useMyObservations( );
const currentUser = useCurrentUser( );
const isDefaultSort = state.observationsSort === OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST;
const hasActiveSearch = !!state.searchedTaxon;
const sortLocally = !isDefaultSort && !currentUser;
const isServerAuthoritative = ( !isDefaultSort || hasActiveSearch ) && !!currentUser;
const localObservationIds = useLocalObservationIds( );
const localObservationIds = useLocalObservationIds(
sortLocally
? state.observationsSort
: undefined,
);
const {
observationIds: serverObservationIds,
isLoading,
isFetchingNextPage,
error,
fetchNextPage,
refetch,
} = useServerOrderedObservations( {
sortBy: state.observationsSort,
enabled: !isDefaultSort,
taxonId: state.searchedTaxon?.id,
enabled: isServerAuthoritative,
} );
// if we want obs from the server, we'll want to prepend local, unsynced obs to the top
@@ -61,28 +79,39 @@ const useMyObservationsQuery = ( ): UseMyObservationsQueryResult => {
// dedupe in case any locally unsynced obs also exist in the server results
const observationIds = useMemo( ( ) => {
if ( isDefaultSort ) return localObservationIds;
if ( !isServerAuthoritative ) return localObservationIds;
const unsyncedUuids = new Set( unsyncedObservationIds.map( o => o.uuid ) );
return [
...unsyncedObservationIds,
...serverObservationIds.filter( o => !unsyncedUuids.has( o.uuid ) ),
];
}, [isDefaultSort, localObservationIds, unsyncedObservationIds, serverObservationIds] );
}, [
isServerAuthoritative,
localObservationIds,
unsyncedObservationIds,
serverObservationIds,
] );
return {
observationIds,
isServerAuthoritative: !isDefaultSort,
isLoading: isDefaultSort
? false
: isLoading,
error: isDefaultSort
? null
: error,
// since we never fetched for default sort, we don't need to refetch.
// pagination is still handled by useInfiniteObservationsScroll
refetch: isDefaultSort
? NOOP_REFETCH
: refetch,
isServerAuthoritative,
isLoading: isServerAuthoritative
? isLoading
: false,
isFetchingNextPage: isServerAuthoritative
? isFetchingNextPage
: false,
error: isServerAuthoritative
? error
: null,
// pagination only applies to the server-authoritative case; the default sort is handled by
// useInfiniteObservationsScroll, and locally-sorted data for logged-out users loads from Realm
refetch: isServerAuthoritative
? refetch
: NOOP_REFETCH,
fetchNextPage: isServerAuthoritative
? fetchNextPage
: NOOP_FETCH_NEXT_PAGE,
};
};

View File

@@ -4,7 +4,7 @@ import Observation from "realmModels/Observation";
import { log } from "sharedHelpers/logger";
import type { OBSERVATIONS_SORT } from "sharedHelpers/observationsSort";
import { observationSortToApiParams } from "sharedHelpers/observationsSort";
import { useAuthenticatedQuery, useCurrentUser } from "sharedHooks";
import { useAuthenticatedInfiniteQuery, useCurrentUser } from "sharedHooks";
const { useRealm } = RealmContext;
@@ -18,79 +18,99 @@ interface SearchObservationsResult {
}
interface SearchObservationsResponse {
page: number;
results: SearchObservationsResult[];
total_results: number;
}
interface ServerOrderedObservationsData {
observationIds: { uuid: string }[];
totalResults: number;
}
interface UseServerOrderedObservationsParams {
sortBy: OBSERVATIONS_SORT;
page?: number;
taxonId?: number;
enabled?: boolean;
}
interface UseServerOrderedObservationsResult {
observationIds: { uuid: string }[];
isLoading: boolean;
isFetchingNextPage: boolean;
error: Error | null;
totalResults?: number;
fetchNextPage: ( ) => void;
refetch: ( ) => void;
}
const useServerOrderedObservations = ( {
sortBy,
page = 1,
taxonId,
enabled = true,
}: UseServerOrderedObservationsParams ): UseServerOrderedObservationsResult => {
const realm = useRealm( );
const currentUser = useCurrentUser( );
const params = {
const baseParams = {
user_id: currentUser?.id,
...observationSortToApiParams( sortBy ),
page,
...( taxonId
? { taxon_id: taxonId }
: {} ),
per_page: PER_PAGE,
fields: Observation.ADVANCED_MODE_LIST_FIELDS,
// Bypass API response caching so newly created/updated observations show up
ttl: -1,
};
const queryKey = ["useServerOrderedObservations", params];
const queryKey = ["useServerOrderedObservations", baseParams];
const {
data,
isLoading,
isFetchingNextPage,
error,
fetchNextPage,
refetch,
} = useAuthenticatedQuery<ServerOrderedObservationsData>(
} = useAuthenticatedInfiniteQuery(
queryKey,
async ( optsWithAuth ): Promise<ServerOrderedObservationsData> => {
async ( { pageParam = 1 }, optsWithAuth ): Promise<SearchObservationsResponse> => {
const params = {
...baseParams,
page: pageParam,
};
const rawResponse = await searchObservations( params, optsWithAuth );
const response = rawResponse as SearchObservationsResponse;
const results = response.results || [];
// upsert results to Realm
try {
Observation.upsertRemoteObservations( results, realm );
} catch ( upsertError ) {
// A local Realm-write failure shouldn't be reported as a failed search
logger.error( "Failed to upsert server-ordered observations", upsertError );
}
return {
observationIds: results.map( ( { uuid } ) => ( { uuid } ) ),
totalResults: response.total_results,
};
return response;
},
{
getNextPageParam: ( lastPage: SearchObservationsResponse ) => {
if ( !lastPage ) return null;
const totalFetchedCount = lastPage.page * PER_PAGE;
return totalFetchedCount < lastPage.total_results
? lastPage.page + 1
: null;
},
enabled: enabled && !!currentUser,
},
{ enabled: enabled && !!currentUser },
);
const pages: SearchObservationsResponse[] = data?.pages || [];
const observationIds = pages
.flatMap( page => page.results || [] )
.map( ( { uuid } ) => ( { uuid } ) );
return {
observationIds: data?.observationIds || [],
observationIds,
isLoading,
isFetchingNextPage,
error,
totalResults: data?.totalResults,
totalResults: pages[0]?.total_results,
fetchNextPage,
refetch,
};
};

View File

@@ -146,8 +146,9 @@ const UserProfile = ( ) => {
const onJournalPostsPressed = ( ) => {
navigation.navigate( "Journal", {
userIcon: User.uri( user ),
userId: user?.id,
userLogin: user?.login,
journalPostsCount: user?.journal_posts_count,
} );
};

View File

@@ -1350,6 +1350,8 @@ Unknown--user = Unknown
# Generic error message
Unknown-error = Unknown error
Unknown-organism = Unknown organism
# Header title for the Explore context showing species the user has not observed
Unobserved = Unobserved
Unreviewed-observations-only = Unreviewed observations only
Upload-Complete = Upload Complete
Upload-in-progress = Upload in progress

View File

@@ -860,6 +860,7 @@
"Unknown--user": "Unknown",
"Unknown-error": "Unknown error",
"Unknown-organism": "Unknown organism",
"Unobserved": "Unobserved",
"Unreviewed-observations-only": "Unreviewed observations only",
"Upload-Complete": "Upload Complete",
"Upload-in-progress": "Upload in progress",

View File

@@ -1350,6 +1350,8 @@ Unknown--user = Unknown
# Generic error message
Unknown-error = Unknown error
Unknown-organism = Unknown organism
# Header title for the Explore context showing species the user has not observed
Unobserved = Unobserved
Unreviewed-observations-only = Unreviewed observations only
Upload-Complete = Upload Complete
Upload-in-progress = Upload in progress

View File

@@ -7,8 +7,6 @@ import UiLibrary from "components/Developer/UiLibrary";
import UiLibraryItem from "components/Developer/UiLibraryItem";
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";
@@ -204,14 +202,6 @@ const TabStackNavigator = ( { route }: BottomTabProps ) => {
name="Explore"
component={ExploreContainer}
/>
<Stack.Screen
name="ExploreFilters"
component={ExploreFiltersContainer}
/>
<Stack.Screen
name="ExploreSearch"
component={ExploreSearchContainer}
/>
<Stack.Screen
name="ObsDetails"
component={FadeInObsDetailsScreen}

View File

@@ -62,7 +62,7 @@ export type SharedStackParamList = {
// From MatchContainer
// undefined
LocationPicker: undefined;
// From ExploreSearchContainer, ExploreTaxonSearchModal, MyObservationsSimple,
// From ExploreTaxonSearchModal, MyObservationsSimple,
// ObsDetailsOverview, CommunityTaxon, SavedMatchContainer, ActivityItem, ProjectRequirements
// TaxonGridItem
// { id: taxon.id }
@@ -248,8 +248,6 @@ export type BaseTabStackParamList = {
place?: ApiPlace | null;
worldwide: boolean;
};
ExploreFilters: undefined;
ExploreSearch: undefined;
// From NotificationsListItem
// {
// uuid: notification.resource_uuid,
@@ -327,8 +325,9 @@ export type BaseTabStackParamList = {
};
// From UserProfile
// {
// userId: user?.id,
// userIcon: user?.icon_url,
// userLogin: user?.login,
// journalPostsCount: user?.journal_posts_count,
// }
// From ProjectDetails
// {
@@ -337,11 +336,12 @@ export type BaseTabStackParamList = {
// projectTitle: project?.title,
// }
Journal: {
userLogin?: string;
projectIcon?: string;
projectId?: number;
projectTitle?: string;
journalPostsCount?: number;
userIcon?: string;
userId?: number;
userLogin?: string;
} | undefined;
Debug: undefined;
UILibrary: undefined;

View File

@@ -3,15 +3,9 @@ import { OBSERVATIONS_TAB } from "appConstants/tabs";
import * as React from "react";
import { OBSERVATIONS_SORT } from "sharedHelpers/observationsSort";
// 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";
import { checkLocationPermissions } from "../sharedHelpers/geolocationWrapper";
export enum EXPLORE_V2_ACTION {
SET_SUBJECT = "SET_SUBJECT",
CLEAR_SUBJECT = "CLEAR_SUBJECT",
SET_LOCATION_NEEDS_PERMISSION = "SET_LOCATION_NEEDS_PERMISSION",
SET_LOCATION_NEARBY = "SET_LOCATION_NEARBY",
SET_LOCATION_WORLDWIDE = "SET_LOCATION_WORLDWIDE",
SET_LOCATION_PLACE = "SET_LOCATION_PLACE",
@@ -22,8 +16,6 @@ export enum EXPLORE_V2_ACTION {
}
export enum EXPLORE_V2_PLACE_MODE {
UNINITIALIZED = "UNINITIALIZED",
NEEDS_PERMISSION = "NEEDS_PERMISSION",
NEARBY = "NEARBY",
WORLDWIDE = "WORLDWIDE",
PLACE = "PLACE"
@@ -60,7 +52,8 @@ export type ExploreV2Tab = typeof OBSERVATIONS_TAB | typeof SPECIES_TAB;
export type ExploreV2Subject =
| { type: "taxon"; taxon: Taxon }
| { type: "user"; user: User }
| { type: "project"; project: Project };
| { type: "project"; project: Project }
| { type: "unobserved"; user: User };
// To be added to in MOB-1346
export interface ExploreV2Filters {
@@ -68,15 +61,8 @@ export interface ExploreV2Filters {
}
export type ExploreV2LocationState =
| { placeMode: EXPLORE_V2_PLACE_MODE.UNINITIALIZED }
| { placeMode: EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION }
| { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE }
| {
placeMode: EXPLORE_V2_PLACE_MODE.NEARBY;
lat: number;
lng: number;
radius: number;
}
| { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY }
| { placeMode: EXPLORE_V2_PLACE_MODE.PLACE; place: Place };
export interface ExploreV2State {
@@ -90,13 +76,7 @@ export interface ExploreV2State {
export type ExploreV2Action =
| { type: EXPLORE_V2_ACTION.SET_SUBJECT; subject: ExploreV2Subject }
| { type: EXPLORE_V2_ACTION.CLEAR_SUBJECT }
| { type: EXPLORE_V2_ACTION.SET_LOCATION_NEEDS_PERMISSION }
| {
type: EXPLORE_V2_ACTION.SET_LOCATION_NEARBY;
lat: number;
lng: number;
radius: number;
}
| { type: EXPLORE_V2_ACTION.SET_LOCATION_NEARBY }
| { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE }
| {
type: EXPLORE_V2_ACTION.SET_LOCATION_PLACE;
@@ -109,7 +89,7 @@ export type ExploreV2Action =
export const initialExploreV2State: ExploreV2State = {
subject: null,
location: { placeMode: EXPLORE_V2_PLACE_MODE.UNINITIALIZED },
location: { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY },
sortBy: OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST,
filters: {},
activeTab: OBSERVATIONS_TAB,
@@ -124,20 +104,10 @@ export function exploreV2Reducer(
return { ...state, subject: action.subject };
case EXPLORE_V2_ACTION.CLEAR_SUBJECT:
return { ...state, subject: null };
case EXPLORE_V2_ACTION.SET_LOCATION_NEEDS_PERMISSION:
return {
...state,
location: { placeMode: EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION },
};
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,
},
location: { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY },
};
case EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE:
return {
@@ -168,37 +138,9 @@ export function exploreV2Reducer(
}
}
export type DefaultExploreV2Location =
| { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE }
| { placeMode: EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION }
| {
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.NEARBY,
lat: location.latitude,
lng: location.longitude,
radius: 1,
};
}
// No coordinates, fallback to worldwide if we already have perms
const hasPermission = await checkLocationPermissions( );
return hasPermission
? { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE }
: { placeMode: EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION };
}
interface ExploreV2ContextValue {
state: ExploreV2State;
dispatch: ( action: ExploreV2Action ) => void;
requestLocationPermissions: ( ) => void;
}
const ExploreV2Context = React.createContext<ExploreV2ContextValue | undefined>(
@@ -207,18 +149,14 @@ const ExploreV2Context = React.createContext<ExploreV2ContextValue | undefined>(
interface ExploreV2ProviderProps {
children: React.ReactNode;
requestLocationPermissions: ( ) => void;
}
export const ExploreV2Provider = ( {
children,
requestLocationPermissions,
}: ExploreV2ProviderProps ) => {
export const ExploreV2Provider = ( { children }: ExploreV2ProviderProps ) => {
const [state, dispatch] = React.useReducer( exploreV2Reducer, initialExploreV2State );
const value = React.useMemo(
() => ( { state, dispatch, requestLocationPermissions } ),
[state, requestLocationPermissions],
() => ( { state, dispatch } ),
[state],
);
return (

View File

@@ -1,6 +1,6 @@
import { Realm } from "@realm/react";
import type { ApiObservationFieldValue } from "api/types";
import type { RealmObservation, RealmObservationFieldValue } from "realmModels/types";
import type { RealmObservationPojo } from "realmModels/types";
import * as uuid from "uuid";
class ObservationFieldValue extends Realm.Object {
@@ -32,9 +32,9 @@ class ObservationFieldValue extends Realm.Object {
}
static findForObsField(
observation: RealmObservation,
observation: RealmObservationPojo,
obsFieldId: number,
): RealmObservationFieldValue | undefined {
) {
return observation.observationFieldValues.find(
ofv => ofv.obsFieldId === obsFieldId,
);

View File

@@ -36,6 +36,25 @@ export function observationSortToApiParams( sort: OBSERVATIONS_SORT ): Observati
return OBSERVATIONS_SORT_TO_API_PARAMS[sort];
}
// [property, reverse] tuple, matching Realm's .sorted() argument shape
type ObservationRealmSort = [string, boolean];
const OBSERVATIONS_SORT_TO_REALM_SORT: Record<OBSERVATIONS_SORT, ObservationRealmSort> = {
[OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST]: ["_created_at", true],
[OBSERVATIONS_SORT.DATE_UPLOADED_OLDEST]: ["_created_at", false],
// observed_on (a real date) is only populated once an observation has been uploaded and the
// server computes it -- local-only observations only ever have the
// observed_on_string set, so local sorting needs to use that field instead
[OBSERVATIONS_SORT.DATE_OBSERVED_NEWEST]: ["observed_on_string", true],
[OBSERVATIONS_SORT.DATE_OBSERVED_OLDEST]: ["observed_on_string", false],
};
// For sorting local, unsynced observations directly in a Realm query -- used for logged-out
// users, who can never have server-ordered observations
export function observationSortToRealmSort( sort: OBSERVATIONS_SORT ): ObservationRealmSort {
return OBSERVATIONS_SORT_TO_REALM_SORT[sort];
}
export function useObservationsSortLabels( ): Record<OBSERVATIONS_SORT, ObservationSortLabels> {
const { t } = useTranslation( );
return {

View File

@@ -0,0 +1,106 @@
import ObservationFieldValue from "realmModels/ObservationFieldValue";
import type { RealmObservationPojo } from "realmModels/types";
// Machine-readable reason codes. UI layers map these to localized
// strings; membership-rule validation uses a separate module with its
// own error strings.
export const MISSING_REQUIRED = "MISSING_REQUIRED" as const;
export const INVALID_NUMERIC = "INVALID_NUMERIC" as const;
export type ProjectFieldValidationReason =
| typeof MISSING_REQUIRED
| typeof INVALID_NUMERIC;
export interface ProjectFieldValidationError {
projectId: number;
projectTitle: string;
obsFieldId: number;
fieldName: string;
reason: ProjectFieldValidationReason;
}
export interface ProjectFieldValidationResult {
valid: boolean;
errors: ProjectFieldValidationError[];
}
interface ObservationFieldToValidate {
datatype?: string;
id: number;
name?: string;
}
interface ProjectObservationFieldToValidate {
obsField: ObservationFieldToValidate | null;
required: boolean;
}
/**
* Validates a single value param against a POF.
*
* Rules (parity with Android Legacy ProjectFieldViewer.isValid()):
* - required: value must be non-empty after trim
* - numeric datatype: a non-empty value must parse as a float, whether or
* not the field is required
*
* Returns null when valid.
*/
export function validateProjectFieldValue(
pof: ProjectObservationFieldToValidate,
value?: string | null,
): ProjectFieldValidationReason | null {
const trimmed = ( value ?? "" ).trim( );
if ( pof.required && trimmed === "" ) {
return MISSING_REQUIRED;
}
if (
pof.obsField?.datatype === "numeric"
&& trimmed !== ""
// Number( ), unlike parseFloat( ), rejects things like
// "1.5abc", matching Android Legacy's Float.valueOf
&& !Number.isFinite( Number( trimmed ) )
) {
return INVALID_NUMERIC;
}
return null;
}
interface ProjectToValidate {
id: number;
projectObservationFields: ProjectObservationFieldToValidate[];
title?: string;
}
/**
* Validates an observation's OFVs against the observation fields (POFs) of
* the given projects.
*
* OFVs are global per observation and keyed by obsFieldId, so two projects
* sharing a field read the same OFV; when a shared required field is empty,
* each project reports its own error.
*/
export default function validateProjectFieldsForObservation(
// currentObservation in zustand is typed as RealmObservationPojo
observation: RealmObservationPojo,
projects: ProjectToValidate[],
): ProjectFieldValidationResult {
const errors: ProjectFieldValidationError[] = [];
projects.forEach( project => {
project.projectObservationFields.forEach( pof => {
const { obsField } = pof;
if ( !obsField ) { return; }
const ofv = ObservationFieldValue.findForObsField( observation, obsField.id );
const reason = validateProjectFieldValue( pof, ofv?.value );
if ( reason ) {
errors.push( {
projectId: project.id,
projectTitle: project.title ?? "",
obsFieldId: obsField.id,
fieldName: obsField.name ?? "",
reason,
} );
}
} );
} );
return { valid: errors.length === 0, errors };
}

View File

@@ -1,19 +1,27 @@
import { RealmContext } from "providers/contexts";
import { useMemo } from "react";
import Observation from "realmModels/Observation";
import type { OBSERVATIONS_SORT } from "sharedHelpers/observationsSort";
import { observationSortToRealmSort } from "sharedHelpers/observationsSort";
const { useQuery } = RealmContext;
const useLocalObservationIds = ( ) => {
const useLocalObservationIds = ( sortBy?: OBSERVATIONS_SORT ) => {
const unsyncedObs = useQuery(
{
type: Observation,
query: observations => observations
.filtered( "_deleted_at == nil OR _pending_deletion == false OR _pending_deletion == nil" )
.sorted( [["needs_sync", true], ["_created_at", true]] ),
keyPaths: ["uuid"],
.sorted( sortBy
? [observationSortToRealmSort( sortBy )]
: [["needs_sync", true], ["_created_at", true]] ),
// widening this beyond uuid means more frequent re-renders for every consumer,
// so we only do it when we need a local sort applied to the results
keyPaths: sortBy
? ["uuid", "_created_at", "observed_on_string"]
: ["uuid"],
},
[],
[sortBy],
);
return useMemo(

View File

@@ -7,6 +7,7 @@ export interface SpeciesCountParams extends ApiParams {
taxon_id?: number;
user_id?: number;
project_id?: number;
unobserved_by_user_id?: number;
place_id?: number;
lat?: number;
lng?: number;

View File

@@ -89,7 +89,7 @@ interface ObservationFlowActions {
setRollbackSnapshot: ( ) => void;
}
type ObservationFlowSlice = ObservationFlowState & ObservationFlowActions;
export type ObservationFlowSlice = ObservationFlowState & ObservationFlowActions;
const DEFAULT_STATE: ObservationFlowState = {
aICameraSuggestion: null,

View File

@@ -6,5 +6,5 @@ export default define( "LocalProjectObservationField", faker => ( {
id: faker.number.int( ),
obsField: ofFactory( "LocalObservationField" ),
position: faker.number.int( ),
required: faker.datatype.boolean( 0.5 ),
required: true,
} ) );

View File

@@ -6,5 +6,5 @@ export default define( "RemoteProjectObservationField", faker => ( {
id: faker.number.int(),
observation_field: ofFactory( "RemoteObservationField" ),
position: faker.number.int(),
required: faker.datatype.boolean( 0.5 ),
required: true,
} ) );

View File

@@ -73,16 +73,8 @@ describe( "AddToProjects", ( ) => {
// TODO: MOB-1503 also check for expanded chooser being shown
} );
it( "renders existing project observations as checked", ( ) => {
renderAddToProjects( );
expect(
within( screen.getByTestId( `AddToProjects.project.${mockProjects[0].id}` ) )
.getByText( iconGlyph( "checkmark-circle" ) ),
).toBeVisible( );
expect(
within( screen.getByTestId( `AddToProjects.project.${mockProjects[1].id}` ) )
.queryByText( iconGlyph( "checkmark-circle" ) ),
).toBeNull( );
} );
// TODO: MOB-1498
// In MOB-1499 we changed the UI state for checked to be entirely driven by OFV validation
// jump-starting the UI with existing POFs has not been implemented yet.
it.todo( "renders existing project observations as checked" );
} );

View File

@@ -37,26 +37,48 @@ describe( "buildExploreV2QueryParams", ( ) => {
const params = buildExploreV2QueryParams( state );
expect( params.project_id ).toBe( 12 );
} );
it( "maps an unobserved subject to unobserved_by_user_id", ( ) => {
const state = {
...initialExploreV2State,
subject: { type: "unobserved", user: { id: 99 } },
};
const params = buildExploreV2QueryParams( state );
expect( params.unobserved_by_user_id ).toBe( 99 );
expect( params.user_id ).toBeUndefined( );
expect( params.taxon_id ).toBeUndefined( );
} );
} );
describe( "location", ( ) => {
it( "includes lat/lng/radius in NEARBY mode with coords", ( ) => {
it( "includes lat/lng/radius in NEARBY mode when coords are resolved", ( ) => {
const state = {
...initialExploreV2State,
location: {
placeMode: EXPLORE_V2_PLACE_MODE.NEARBY,
lat: 37.5,
lng: -122.1,
radius: 1,
},
location: { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY },
};
const params = buildExploreV2QueryParams( state );
const params = buildExploreV2QueryParams( state, {
lat: 37.5,
lng: -122.1,
radius: 1,
} );
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 in NEARBY mode when coords are unresolved (worldwide fallback)", ( ) => {
const state = {
...initialExploreV2State,
location: { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY },
};
const params = buildExploreV2QueryParams( state );
expect( params.lat ).toBeUndefined( );
expect( params.lng ).toBeUndefined( );
expect( params.radius ).toBeUndefined( );
expect( params.place_id ).toBeUndefined( );
} );
it( "omits coords and place in WORLDWIDE mode", ( ) => {
const state = {
...initialExploreV2State,
@@ -67,17 +89,6 @@ describe( "buildExploreV2QueryParams", ( ) => {
expect( params.place_id ).toBeUndefined( );
} );
it( "omits coords and place in NEEDS_PERMISSION mode", ( ) => {
const state = {
...initialExploreV2State,
location: { placeMode: EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION },
};
const params = buildExploreV2QueryParams( state );
expect( params.lat ).toBeUndefined( );
expect( params.lng ).toBeUndefined( );
expect( params.place_id ).toBeUndefined( );
} );
it( "uses place_id in PLACE mode", ( ) => {
const state = {
...initialExploreV2State,
@@ -145,16 +156,15 @@ describe( "buildExploreV2QueryParams", ( ) => {
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,
},
location: { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY },
sortBy: OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST,
filters: {},
};
const params = buildExploreV2QueryParams( state );
const params = buildExploreV2QueryParams( state, {
lat: 37.5,
lng: -122.1,
radius: 1,
} );
expect( params ).toEqual( {
per_page: 20,
verifiable: true,

View File

@@ -124,6 +124,19 @@ describe( "ExploreV2Header", () => {
expect( screen.getByTestId( "IconicTaxonName.iconicTaxonIcon" ) ).toBeTruthy();
} );
it( "renders the Unobserved title and location without a subject thumbnail", () => {
setState(
{ type: "unobserved", user: { id: 7, login: "seth_msp" } },
{ placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE },
);
renderComponent( <ExploreV2Header /> );
expect( screen.getByText( "Unobserved" ) ).toBeTruthy();
expect( screen.getByText( "Worldwide" ) ).toBeTruthy();
expect( screen.getByTestId( "ExploreV2Header.unobserved" ) ).toBeTruthy();
expect( screen.queryByTestId( "ExploreV2Header.subject" ) ).toBeNull();
} );
it( "renders only the place name when there is no subject", () => {
setState( null );
renderComponent( <ExploreV2Header /> );
@@ -140,19 +153,7 @@ describe( "ExploreV2Header", () => {
} );
it( "renders the Nearby label when location is nearby", () => {
setState( null, {
placeMode: EXPLORE_V2_PLACE_MODE.NEARBY,
lat: 1,
lng: 2,
radius: 1,
} );
renderComponent( <ExploreV2Header /> );
expect( screen.getByText( "Nearby" ) ).toBeTruthy();
} );
it( "renders the Nearby label when nearby is intended but permission is pending", () => {
setState( null, { placeMode: EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION } );
setState( null, { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY } );
renderComponent( <ExploreV2Header /> );
expect( screen.getByText( "Nearby" ) ).toBeTruthy();

View File

@@ -50,7 +50,7 @@ const StateProbe = () => {
const actor = userEvent.setup( );
const renderGridItem = ( props = {} ) => renderComponent(
<ExploreV2Provider requestLocationPermissions={() => {}}>
<ExploreV2Provider>
<ExploreV2SpeciesGridItem taxon={mockTaxon} {...props} />
<StateProbe />
</ExploreV2Provider>,

View File

@@ -7,7 +7,7 @@ import { renderComponent } from "tests/helpers/render";
const actor = userEvent.setup( );
const renderTabs = props => renderComponent(
<ExploreV2Provider requestLocationPermissions={() => {}}>
<ExploreV2Provider>
<ExploreV2Tabs {...props} />
</ExploreV2Provider>,
);

View File

@@ -0,0 +1,188 @@
import { screen, waitFor } from "@testing-library/react-native";
import ExploreResults from "components/Explore/ExploreV2/screens/ExploreResults";
import initI18next from "i18n/initI18next";
import {
EXPLORE_V2_ACTION,
EXPLORE_V2_PLACE_MODE,
initialExploreV2State,
} from "providers/ExploreV2Context";
import React from "react";
import { renderComponent } from "tests/helpers/render";
jest.mock( "@react-navigation/native", ( ) => {
const actualNav = jest.requireActual( "@react-navigation/native" );
return {
...actualNav,
useFocusEffect: cb => jest.requireActual( "react" ).useEffect( cb, [] ),
};
} );
jest.mock( "providers/ExploreV2Context", ( ) => {
const actual = jest.requireActual( "providers/ExploreV2Context" );
return { ...actual, useExploreV2: jest.fn( ) };
} );
const { useExploreV2 } = require( "providers/ExploreV2Context" );
let mockHasPermissions;
let mockHasBlockedPermissions;
const mockDispatch = jest.fn( );
const mockRequestPermissions = jest.fn( );
jest.mock( "sharedHooks/useLocationPermission", ( ) => ( {
__esModule: true,
default: ( ) => ( {
hasPermissions: mockHasPermissions,
hasBlockedPermissions: mockHasBlockedPermissions,
renderPermissionsGate: ( ) => null,
requestPermissions: mockRequestPermissions,
} ),
} ) );
jest.mock( "sharedHelpers/fetchCoarseUserLocation", ( ) => ( {
__esModule: true,
default: jest.fn( ),
} ) );
const fetchCoarseUserLocation = require( "sharedHelpers/fetchCoarseUserLocation" ).default;
const mockUseInfiniteExploreScroll = jest.fn( );
jest.mock( "components/Explore/hooks/useInfiniteExploreScroll", ( ) => ( {
__esModule: true,
default: args => mockUseInfiniteExploreScroll( args ),
} ) );
jest.mock( "sharedHooks/useSpeciesCount", ( ) => ( { __esModule: true, default: ( ) => 0 } ) );
const mockState = location => ( {
...initialExploreV2State,
location,
} );
const lastScrollArgs = ( ) => mockUseInfiniteExploreScroll.mock.calls.at( -1 )[0];
beforeAll( async ( ) => {
await initI18next( );
} );
beforeEach( ( ) => {
mockHasPermissions = undefined;
mockHasBlockedPermissions = false;
mockDispatch.mockClear( );
mockRequestPermissions.mockClear( );
fetchCoarseUserLocation.mockReset( );
mockUseInfiniteExploreScroll.mockReset( );
mockUseInfiniteExploreScroll.mockReturnValue( {
fetchNextPage: jest.fn( ),
isFetchingNextPage: false,
handlePullToRefresh: jest.fn( ),
observations: [],
totalResults: 0,
} );
} );
describe( "ExploreResults nearby resolution", ( ) => {
it( "includes fetched coordinates in the query when nearby with permission", async ( ) => {
mockHasPermissions = true;
fetchCoarseUserLocation.mockResolvedValueOnce( { latitude: 37.5, longitude: -122.1 } );
useExploreV2.mockReturnValue( {
state: mockState( { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY } ),
dispatch: mockDispatch,
} );
renderComponent( <ExploreResults /> );
await waitFor( ( ) => {
const { params, enabled } = lastScrollArgs( );
expect( params.lat ).toBe( 37.5 );
expect( params.lng ).toBe( -122.1 );
expect( params.radius ).toBe( 1 );
expect( enabled ).toBe( true );
} );
} );
it( "shows the permission prompt and does not fetch when nearby, no permission", async ( ) => {
mockHasPermissions = false;
useExploreV2.mockReturnValue( {
state: mockState( { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY } ),
dispatch: mockDispatch,
} );
renderComponent( <ExploreResults /> );
expect(
await screen.findByText( /To view nearby organisms/ ),
).toBeVisible( );
expect( fetchCoarseUserLocation ).not.toHaveBeenCalled( );
expect( lastScrollArgs( ).enabled ).toBe( false );
} );
it( "does not show stale counts in the tabs when nearby, no permission", async ( ) => {
// Simulate the shared query cache still holding the previous (worldwide)
// result: the disabled query returns a non-zero totalResults.
mockUseInfiniteExploreScroll.mockReturnValue( {
fetchNextPage: jest.fn( ),
isFetchingNextPage: false,
handlePullToRefresh: jest.fn( ),
observations: [],
totalResults: 42,
} );
mockHasPermissions = false;
useExploreV2.mockReturnValue( {
state: mockState( { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY } ),
dispatch: mockDispatch,
} );
renderComponent( <ExploreResults /> );
expect(
await screen.findByText( /To view nearby organisms/ ),
).toBeVisible( );
// The stale worldwide count must not leak into the tabs.
expect( screen.queryByText( "42" ) ).toBeNull( );
expect( screen.getAllByText( "--" ).length ).toBeGreaterThanOrEqual( 1 );
} );
it( "dispatches worldwide without prompting when permission is blocked", async ( ) => {
mockHasPermissions = false;
mockHasBlockedPermissions = true;
useExploreV2.mockReturnValue( {
state: mockState( { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY } ),
dispatch: mockDispatch,
} );
renderComponent( <ExploreResults /> );
await waitFor( ( ) => expect( mockDispatch ).toHaveBeenCalledWith( {
type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE,
} ) );
expect( screen.queryByText( /To view nearby organisms/ ) ).toBeNull( );
expect( fetchCoarseUserLocation ).not.toHaveBeenCalled( );
} );
it( "dispatches worldwide when permission is granted but there is no fix", async ( ) => {
mockHasPermissions = true;
fetchCoarseUserLocation.mockResolvedValueOnce( null );
useExploreV2.mockReturnValue( {
state: mockState( { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY } ),
dispatch: mockDispatch,
} );
renderComponent( <ExploreResults /> );
await waitFor( ( ) => expect( mockDispatch ).toHaveBeenCalledWith( {
type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE,
} ) );
} );
it( "fetches worldwide without coordinates when worldwide", async ( ) => {
mockHasPermissions = true;
useExploreV2.mockReturnValue( {
state: mockState( { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE } ),
dispatch: mockDispatch,
} );
renderComponent( <ExploreResults /> );
await waitFor( ( ) => expect( lastScrollArgs( ).enabled ).toBe( true ) );
expect( lastScrollArgs( ).params.lat ).toBeUndefined( );
expect( fetchCoarseUserLocation ).not.toHaveBeenCalled( );
} );
} );

View File

@@ -72,7 +72,7 @@ jest.mock( "@tanstack/react-query", () => {
} );
const renderView = ( props = {} ) => renderComponent(
<ExploreV2Provider requestLocationPermissions={() => {}}>
<ExploreV2Provider>
<ExploreV2SpeciesView
enabled
isConnected

View File

@@ -1,3 +1,4 @@
import { useNetInfo } from "@react-native-community/netinfo";
import {
act, fireEvent, screen, userEvent, waitFor,
} from "@testing-library/react-native";
@@ -182,6 +183,8 @@ beforeEach( ( ) => {
useIconicTaxa.mockReturnValue( ICONIC_TAXA );
useUniversalSearch.mockReturnValue( { results: [], isLoading: false, refetch: jest.fn( ) } );
useLocationSearch.mockReturnValue( { results: [], isLoading: false, refetch: jest.fn( ) } );
// Default to online; the offline tests override this per-case.
useNetInfo.mockReturnValue( { isConnected: true } );
} );
afterEach( ( ) => {
@@ -311,6 +314,34 @@ describe( "UniversalSearch screen", ( ) => {
).toBeNull( );
} );
describe( "offline state", ( ) => {
it( "shows the offline notice, not the no-results message, when offline with a query", ( ) => {
useNetInfo.mockReturnValue( { isConnected: false } );
renderComponent( <UniversalSearch /> );
typeQuery( "ver" );
expect(
screen.getByText( i18next.t( "You-are-offline-Tap-to-try-again" ) ),
).toBeTruthy( );
expect(
screen.queryByText( i18next.t( "No-results-found-for-that-search" ) ),
).toBeNull( );
} );
it( "retries the subject search when the offline notice is tapped", async ( ) => {
const refetch = jest.fn( );
useNetInfo.mockReturnValue( { isConnected: false } );
useUniversalSearch.mockReturnValue( { results: [], isLoading: false, refetch } );
renderComponent( <UniversalSearch /> );
typeQuery( "ver" );
await actor.press( screen.getByLabelText( i18next.t( "Internet-Connection-Required" ) ) );
expect( refetch ).toHaveBeenCalled( );
} );
} );
it( "does not show results until the user has typed a query", ( ) => {
useUniversalSearch.mockReturnValue( {
results: MIXED_RESULTS,
@@ -382,6 +413,30 @@ describe( "UniversalSearch screen", ( ) => {
);
} );
it( "stages an unobserved subject when the unobserved row is tapped", async ( ) => {
renderComponent( <UniversalSearch /> );
await actor.press( screen.getByTestId( "DefaultSearchOptions.unobserved" ) );
// the selection is staged locally, not written to context until Search
expect( mockDispatch ).not.toHaveBeenCalled( );
// the subject field shows the "Species I haven't observed" label
expect(
screen.getByDisplayValue( i18next.t( "Species-I-havent-observed" ) ),
).toBeTruthy( );
await actor.press( screen.getByTestId( "UniversalSearch.searchButton" ) );
expect( mockDispatch ).toHaveBeenCalledWith(
expect.objectContaining( {
type: "SET_SUBJECT",
subject: expect.objectContaining( {
type: "unobserved",
user: expect.objectContaining( { id: 99 } ),
} ),
} ),
);
} );
it( "hides the current user row when logged out", ( ) => {
useCurrentUser.mockReturnValue( null );
renderComponent( <UniversalSearch /> );
@@ -534,8 +589,7 @@ describe( "UniversalSearch screen", ( ) => {
expect( mockDispatch ).toHaveBeenCalledWith( { type: "SET_LOCATION_WORLDWIDE" } );
} );
it( "fills the field and stages nearby when Nearby is tapped", async ( ) => {
fetchCoarseUserLocation.mockResolvedValue( { latitude: 10, longitude: 20 } );
it( "fills the field and stages the nearby intent when Nearby is tapped", async ( ) => {
renderComponent( <UniversalSearch /> );
focusLocation( );
@@ -547,55 +601,25 @@ describe( "UniversalSearch screen", ( ) => {
expect( mockDispatch ).not.toHaveBeenCalled( );
await actor.press( screen.getByTestId( "UniversalSearch.searchButton" ) );
expect( mockDispatch ).toHaveBeenCalledWith( {
type: "SET_LOCATION_NEARBY",
lat: 10,
lng: 20,
radius: 1,
} );
expect( mockDispatch ).toHaveBeenCalledWith( { type: "SET_LOCATION_NEARBY" } );
} );
it(
"stages worldwide when permission is granted but no location fix is available",
"stages the nearby intent (no prompting, no fetch) regardless of permission",
async ( ) => {
fetchCoarseUserLocation.mockResolvedValue( null );
checkLocationPermissions.mockResolvedValue( "granted" );
renderComponent( <UniversalSearch /> );
focusLocation( );
await actor.press( screen.getByRole( "button", { name: i18next.t( "Nearby" ) } ) );
await waitFor( ( ) => {
expect( screen.getByDisplayValue( i18next.t( "Nearby" ) ) ).toBeTruthy( );
} );
expect( mockDispatch ).not.toHaveBeenCalled( );
await actor.press( screen.getByTestId( "UniversalSearch.searchButton" ) );
expect( mockDispatch ).toHaveBeenCalledWith( { type: "SET_LOCATION_WORLDWIDE" } );
expect( mockDispatch ).not.toHaveBeenCalledWith(
{ type: "SET_LOCATION_NEEDS_PERMISSION" },
);
},
);
it(
"stages nearby-needs-permission (without prompting) when permission is missing",
async ( ) => {
fetchCoarseUserLocation.mockResolvedValue( null );
checkLocationPermissions.mockResolvedValue( null );
renderComponent( <UniversalSearch /> );
focusLocation( );
await actor.press( screen.getByRole( "button", { name: i18next.t( "Nearby" ) } ) );
await waitFor( ( ) => {
expect( screen.getByDisplayValue( i18next.t( "Nearby" ) ) ).toBeTruthy( );
} );
expect( fetchCoarseUserLocation ).not.toHaveBeenCalled( );
expect( mockRequestLocationPermissions ).not.toHaveBeenCalled( );
expect( mockDispatch ).not.toHaveBeenCalled( );
await actor.press( screen.getByTestId( "UniversalSearch.searchButton" ) );
expect( mockDispatch ).toHaveBeenCalledWith( { type: "SET_LOCATION_NEEDS_PERMISSION" } );
expect( mockDispatch ).toHaveBeenCalledWith( { type: "SET_LOCATION_NEARBY" } );
},
);
} );

View File

@@ -75,7 +75,7 @@ jest.mock( "sharedHooks/useDeviceOrientation", ( ) => ( {
default: jest.fn( () => ( DEVICE_ORIENTATION_PHONE_PORTRAIT ) ),
} ) );
const renderMyObservations = layout => renderComponent(
const renderMyObservations = ( layout, showSearchEmptyState = false ) => renderComponent(
<MyObservationsProvider>
<MyObservationsSimple
layout={layout}
@@ -84,6 +84,7 @@ const renderMyObservations = layout => renderComponent(
toggleLayout={jest.fn( )}
setShowLoginSheet={jest.fn( )}
activeTab={OBSERVATIONS_TAB}
showSearchEmptyState={showSearchEmptyState}
/>
</MyObservationsProvider>,
);
@@ -131,6 +132,14 @@ describe( "MyObservationsSimple", () => {
} );
} );
it( "renders SearchEmptyState instead of the observations list when a search has no "
+ "results", ( ) => {
renderMyObservations( "list", true );
expect( screen.getByTestId( "MyObservationsSearchEmptyState.reset" ) ).toBeTruthy( );
expect( screen.queryByTestId( "MyObservationsAnimatedList" ) ).toBeNull( );
} );
describe( "grid view", ( ) => {
describe( "portrait orientation", ( ) => {
describe( "on a phone", ( ) => {

View File

@@ -5,6 +5,7 @@ import useServerOrderedObservations
import { useMyObservations } from "providers/MyObservationsContext";
import { OBSERVATIONS_SORT } from "sharedHelpers/observationsSort";
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
import useCurrentUser from "sharedHooks/useCurrentUser";
import factory from "tests/factory";
import setupUniqueRealm from "tests/helpers/uniqueRealm";
@@ -18,6 +19,11 @@ jest.mock( "providers/MyObservationsContext", ( ) => ( {
useMyObservations: jest.fn( ),
} ) );
jest.mock( "sharedHooks/useCurrentUser", ( ) => ( {
__esModule: true,
default: jest.fn( ),
} ) );
// UNIQUE REALM SETUP
const mockRealmIdentifier = __filename;
const { mockRealmModelsIndex, uniqueRealmBeforeAll, uniqueRealmAfterAll } = setupUniqueRealm(
@@ -56,10 +62,14 @@ const createObservation = observation => {
const defaultServerResult = {
observationIds: [],
isLoading: false,
isFetchingNextPage: false,
error: null,
fetchNextPage: jest.fn( ),
refetch: jest.fn( ),
};
const mockUser = factory( "LocalUser" );
beforeEach( ( ) => {
// clear leftover state from previous test
const realm = global.mockRealms[mockRealmIdentifier];
@@ -67,6 +77,7 @@ beforeEach( ( ) => {
realm.deleteAll( );
}, "clear realm before each useMyObservationsQuery test" );
useServerOrderedObservations.mockReturnValue( defaultServerResult );
useCurrentUser.mockReturnValue( mockUser );
} );
afterEach( ( ) => {
@@ -80,11 +91,14 @@ describe( "useMyObservationsQuery", ( ) => {
state: { observationsSort: OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST },
} );
const serverRefetch = jest.fn( );
const serverFetchNextPage = jest.fn( );
useServerOrderedObservations.mockReturnValue( {
observationIds: [{ uuid: "should-be-ignored-in-default-sort" }],
isLoading: true,
isFetchingNextPage: true,
error: new Error( "should be suppressed for default sort" ),
refetch: serverRefetch,
fetchNextPage: serverFetchNextPage,
} );
const localObs = factory( "LocalObservation", { needs_sync: false } );
createObservation( localObs );
@@ -94,8 +108,10 @@ describe( "useMyObservationsQuery", ( ) => {
expect( result.current.observationIds ).toEqual( [{ uuid: localObs.uuid }] );
expect( result.current.isServerAuthoritative ).toEqual( false );
expect( result.current.isLoading ).toEqual( false );
expect( result.current.isFetchingNextPage ).toEqual( false );
expect( result.current.error ).toBeNull( );
expect( result.current.refetch ).not.toBe( serverRefetch );
expect( result.current.fetchNextPage ).not.toBe( serverFetchNextPage );
expect( useServerOrderedObservations ).toHaveBeenCalledWith(
expect.objectContaining( { enabled: false } ),
);
@@ -106,9 +122,12 @@ describe( "useMyObservationsQuery", ( ) => {
state: { observationsSort: OBSERVATIONS_SORT.DATE_OBSERVED_OLDEST },
} );
const serverObs = { uuid: factory( "LocalObservation" ).uuid };
const serverFetchNextPage = jest.fn( );
useServerOrderedObservations.mockReturnValue( {
...defaultServerResult,
observationIds: [serverObs],
isFetchingNextPage: true,
fetchNextPage: serverFetchNextPage,
} );
const unsyncedObs = factory( "LocalObservation", { needs_sync: true } );
createObservation( unsyncedObs );
@@ -120,6 +139,8 @@ describe( "useMyObservationsQuery", ( ) => {
serverObs,
] );
expect( result.current.isServerAuthoritative ).toEqual( true );
expect( result.current.isFetchingNextPage ).toEqual( true );
expect( result.current.fetchNextPage ).toBe( serverFetchNextPage );
expect( useServerOrderedObservations ).toHaveBeenCalledWith(
expect.objectContaining( { enabled: true } ),
);
@@ -144,4 +165,79 @@ describe( "useMyObservationsQuery", ( ) => {
otherServerObs,
] );
} );
it( "is server authoritative for an active taxon search even under the default sort", ( ) => {
const searchedTaxon = { id: 121323, name: "Reptilia" };
useMyObservations.mockReturnValue( {
state: {
observationsSort: OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST,
searchedTaxon,
},
} );
const serverObs = { uuid: factory( "LocalObservation" ).uuid };
useServerOrderedObservations.mockReturnValue( {
...defaultServerResult,
observationIds: [serverObs],
} );
const { result } = renderHook( ( ) => useMyObservationsQuery( ) );
expect( result.current.isServerAuthoritative ).toEqual( true );
expect( result.current.observationIds ).toEqual( [serverObs] );
expect( useServerOrderedObservations ).toHaveBeenCalledWith(
expect.objectContaining( { enabled: true, taxonId: searchedTaxon.id } ),
);
} );
it( "ignores an active taxon search when there is no current user", ( ) => {
useCurrentUser.mockReturnValue( null );
const searchedTaxon = { id: 121323, name: "Reptilia" };
useMyObservations.mockReturnValue( {
state: {
observationsSort: OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST,
searchedTaxon,
},
} );
useServerOrderedObservations.mockReturnValue( {
...defaultServerResult,
observationIds: [{ uuid: "should-be-ignored-when-logged-out" }],
} );
const localObs = factory( "LocalObservation", { needs_sync: false } );
createObservation( localObs );
const { result } = renderHook( ( ) => useMyObservationsQuery( ) );
expect( result.current.isServerAuthoritative ).toEqual( false );
expect( result.current.observationIds ).toEqual( [{ uuid: localObs.uuid }] );
expect( useServerOrderedObservations ).toHaveBeenCalledWith(
expect.objectContaining( { enabled: false } ),
);
} );
it( "applies the selected sort to local observations when there is no current user", ( ) => {
useCurrentUser.mockReturnValue( null );
useMyObservations.mockReturnValue( {
state: { observationsSort: OBSERVATIONS_SORT.DATE_OBSERVED_OLDEST },
} );
useServerOrderedObservations.mockReturnValue( {
...defaultServerResult,
observationIds: [{ uuid: "should-be-ignored-when-logged-out" }],
} );
const olderObs = factory( "LocalObservation", { observed_on_string: "2020-01-01T00:00:00" } );
const newerObs = factory( "LocalObservation", { observed_on_string: "2022-06-15T00:00:00" } );
// create newer-first so a passing test can't be explained by insertion order
createObservation( newerObs );
createObservation( olderObs );
const { result } = renderHook( ( ) => useMyObservationsQuery( ) );
expect( result.current.observationIds ).toEqual( [
{ uuid: olderObs.uuid },
{ uuid: newerObs.uuid },
] );
expect( result.current.isServerAuthoritative ).toEqual( false );
expect( useServerOrderedObservations ).toHaveBeenCalledWith(
expect.objectContaining( { enabled: false } ),
);
} );
} );

View File

@@ -3,7 +3,7 @@ import useServerOrderedObservations
from "components/MyObservations/hooks/useServerOrderedObservations";
import inatjs from "inaturalistjs";
import { OBSERVATIONS_SORT } from "sharedHelpers/observationsSort";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
import useAuthenticatedInfiniteQuery from "sharedHooks/useAuthenticatedInfiniteQuery";
import useCurrentUser from "sharedHooks/useCurrentUser";
import factory, { makeResponse } from "tests/factory";
import setupUniqueRealm from "tests/helpers/uniqueRealm";
@@ -15,7 +15,7 @@ jest.mock( "sharedHooks/useCurrentUser", ( ) => ( {
default: jest.fn( ),
} ) );
jest.mock( "sharedHooks/useAuthenticatedQuery", ( ) => ( {
jest.mock( "sharedHooks/useAuthenticatedInfiniteQuery", ( ) => ( {
__esModule: true,
default: jest.fn( ),
} ) );
@@ -47,13 +47,15 @@ const getLocalObservation = uuid => getRealm( ).objectForPrimaryKey( "Observatio
const defaultQueryResult = {
data: undefined,
isLoading: false,
isFetchingNextPage: false,
error: null,
fetchNextPage: jest.fn( ),
refetch: jest.fn( ),
};
beforeEach( ( ) => {
useCurrentUser.mockReturnValue( mockUser );
useAuthenticatedQuery.mockReturnValue( defaultQueryResult );
useAuthenticatedInfiniteQuery.mockReturnValue( defaultQueryResult );
} );
afterEach( ( ) => {
@@ -66,7 +68,7 @@ describe( "useServerOrderedObservations", ( ) => {
sortBy: OBSERVATIONS_SORT.DATE_OBSERVED_OLDEST,
} ) );
const [queryKey] = useAuthenticatedQuery.mock.calls[0];
const [queryKey] = useAuthenticatedInfiniteQuery.mock.calls[0];
const [, params] = queryKey;
expect( params ).toEqual( expect.objectContaining( {
user_id: mockUser.id,
@@ -74,24 +76,55 @@ describe( "useServerOrderedObservations", ( ) => {
} ) );
} );
it( "includes taxon_id in API params when a taxonId is provided", ( ) => {
renderHook( ( ) => useServerOrderedObservations( {
sortBy: OBSERVATIONS_SORT.DATE_OBSERVED_OLDEST,
taxonId: 121323,
} ) );
const [queryKey] = useAuthenticatedInfiniteQuery.mock.calls[0];
const [, params] = queryKey;
expect( params ).toEqual( expect.objectContaining( {
taxon_id: 121323,
} ) );
} );
it( "omits taxon_id entirely from API params when no taxonId is provided", ( ) => {
renderHook( ( ) => useServerOrderedObservations( {
sortBy: OBSERVATIONS_SORT.DATE_OBSERVED_OLDEST,
} ) );
const [queryKey] = useAuthenticatedInfiniteQuery.mock.calls[0];
const [, params] = queryKey;
expect( params ).not.toHaveProperty( "taxon_id" );
} );
it( "disables the query when enabled is false or there is no current user", ( ) => {
const { rerender } = renderHook(
props => useServerOrderedObservations( props ),
{ initialProps: { sortBy: OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST, enabled: false } },
);
expect( useAuthenticatedQuery.mock.calls[0][2].enabled ).toEqual( false );
expect( useAuthenticatedInfiniteQuery.mock.calls[0][2].enabled ).toEqual( false );
useCurrentUser.mockReturnValue( null );
rerender( { sortBy: OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST, enabled: true } );
expect( useAuthenticatedQuery.mock.calls[1][2].enabled ).toEqual( false );
expect( useAuthenticatedInfiniteQuery.mock.calls[1][2].enabled ).toEqual( false );
} );
it( "passes through the query's uuid-only data and metadata as-is", ( ) => {
it( "flattens uuid-only data across pages and passes through pagination metadata", ( ) => {
const mockRefetch = jest.fn( );
useAuthenticatedQuery.mockReturnValue( {
data: { observationIds: [{ uuid: "a" }, { uuid: "b" }], totalResults: 2 },
const mockFetchNextPage = jest.fn( );
useAuthenticatedInfiniteQuery.mockReturnValue( {
data: {
pages: [
{ results: [{ uuid: "a" }, { uuid: "b" }], total_results: 3, page: 1 },
{ results: [{ uuid: "c" }], total_results: 3, page: 2 },
],
},
isLoading: false,
isFetchingNextPage: true,
error: null,
fetchNextPage: mockFetchNextPage,
refetch: mockRefetch,
} );
@@ -99,12 +132,16 @@ describe( "useServerOrderedObservations", ( ) => {
sortBy: OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST,
} ) );
expect( result.current.observationIds ).toEqual( [{ uuid: "a" }, { uuid: "b" }] );
expect( result.current.totalResults ).toEqual( 2 );
expect( result.current.observationIds ).toEqual( [
{ uuid: "a" }, { uuid: "b" }, { uuid: "c" },
] );
expect( result.current.totalResults ).toEqual( 3 );
expect( result.current.isFetchingNextPage ).toEqual( true );
expect( result.current.fetchNextPage ).toEqual( mockFetchNextPage );
expect( result.current.refetch ).toEqual( mockRefetch );
} );
it( "queryFn upserts fetched results into Realm and returns a uuid-only list", async ( ) => {
it( "requests the next page via pageParam and upserts fetched results into Realm", async ( ) => {
const remoteObservation = factory( "RemoteObservation" );
inatjs.observations.search.mockResolvedValueOnce( makeResponse( [remoteObservation] ) );
@@ -112,15 +149,28 @@ describe( "useServerOrderedObservations", ( ) => {
sortBy: OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST,
} ) );
const [, queryFunction] = useAuthenticatedQuery.mock.calls[0];
const data = await queryFunction( { api_token: "fake-token" } );
const [, queryFunction] = useAuthenticatedInfiniteQuery.mock.calls[0];
const data = await queryFunction( { pageParam: 2 }, { api_token: "fake-token" } );
expect( data ).toEqual( {
observationIds: [{ uuid: remoteObservation.uuid }],
totalResults: 1,
} );
expect( inatjs.observations.search ).toHaveBeenCalledWith(
expect.objectContaining( { page: 2 } ),
expect.anything( ),
);
expect( data.results ).toEqual( [remoteObservation] );
const localObs = getLocalObservation( remoteObservation.uuid );
expect( localObs ).toBeTruthy( );
expect( localObs.id ).toEqual( remoteObservation.id );
} );
describe( "getNextPageParam", ( ) => {
it( "requests the next page until all results have been fetched", ( ) => {
renderHook( ( ) => useServerOrderedObservations( {
sortBy: OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST,
} ) );
const { getNextPageParam } = useAuthenticatedInfiniteQuery.mock.calls[0][2];
expect( getNextPageParam( { page: 1, total_results: 45 } ) ).toEqual( 2 );
expect( getNextPageParam( { page: 3, total_results: 45 } ) ).toBeNull( );
} );
} );
} );

View File

@@ -0,0 +1,533 @@
import validateProjectFieldsForObservation, {
INVALID_NUMERIC,
MISSING_REQUIRED,
validateProjectFieldValue,
} from "sharedHelpers/validateProjectFieldsForObservation";
import factory from "tests/factory";
describe( "validateProjectFieldValue", () => {
describe( "required fields", () => {
test.each( [
[undefined],
[null],
[""],
[" "],
] )( "should return MISSING_REQUIRED for empty value %p", value => {
const mockPOF = factory( "LocalProjectObservationField", {
required: true,
obsField: factory( "LocalObservationField", {
allowedValues: [],
} ),
} );
expect( validateProjectFieldValue( mockPOF, value ) ).toBe( MISSING_REQUIRED );
} );
it( "should return null for a non-empty value", () => {
const mockPOF = factory( "LocalProjectObservationField", {
required: true,
obsField: factory( "LocalObservationField", {
allowedValues: [],
} ),
} );
expect( validateProjectFieldValue( mockPOF, "x" ) ).toBeNull( );
} );
it( "should return null for a value that is non-empty after trim", () => {
const mockPOF = factory( "LocalProjectObservationField", {
required: true,
obsField: factory( "LocalObservationField", {
allowedValues: [],
} ),
} );
expect( validateProjectFieldValue( mockPOF, " x " ) ).toBeNull( );
} );
} );
describe( "numeric fields", () => {
it( "should return INVALID_NUMERIC for a non-numeric value on an optional field", () => {
const mockPOF = factory( "LocalProjectObservationField", {
required: false,
obsField: factory( "LocalObservationField", {
allowedValues: [],
datatype: "numeric",
} ),
} );
expect( validateProjectFieldValue( mockPOF, "abc" ) ).toBe( INVALID_NUMERIC );
} );
test.each( [
[undefined],
[""],
] )( "should return null for empty value %p on an optional numeric field", value => {
const mockPOF = factory( "LocalProjectObservationField", {
required: false,
obsField: factory( "LocalObservationField", {
allowedValues: [],
datatype: "numeric",
} ),
} );
expect( validateProjectFieldValue( mockPOF, value ) ).toBeNull( );
} );
} );
describe( "optional text fields", () => {
test.each( [
[undefined],
[""],
["anything"],
] )( "should return null for %p", value => {
const mockPOF = factory( "LocalProjectObservationField", {
required: false,
obsField: factory( "LocalObservationField", {
allowedValues: [],
} ),
} );
expect( validateProjectFieldValue( mockPOF, value ) ).toBeNull( );
} );
} );
} );
describe( "validateProjectFieldsForObservation", () => {
describe( "required fields", () => {
it( "should be valid when a required field has a non-empty OFV", () => {
const mockProject = {
projectObservationFields: [{
required: true,
obsField: {
allowedValues: [],
id: 10,
},
}],
};
const mockObservation = {
observationFieldValues: [{ obsFieldId: 10, value: "shrubland" }],
};
const result = validateProjectFieldsForObservation( mockObservation, [mockProject] );
expect( result.valid ).toBe( true );
expect( result.errors ).toEqual( [] );
} );
it( "should return MISSING_REQUIRED when a required field has no OFV", () => {
const mockProject = {
title: "Mushrooms of Bavaria",
projectObservationFields: [{
required: true,
obsField: {
allowedValues: [],
id: 10,
name: "Habitat",
},
}],
};
const mockObservation = { observationFieldValues: [] };
const result = validateProjectFieldsForObservation( mockObservation, [mockProject] );
expect( result.valid ).toBe( false );
expect( result.errors ).toEqual( [{
projectId: mockProject.id,
projectTitle: "Mushrooms of Bavaria",
obsFieldId: 10,
fieldName: "Habitat",
reason: MISSING_REQUIRED,
}] );
} );
test.each( [
[""],
[" "],
] )( "should return MISSING_REQUIRED when a required field's OFV value is %p", value => {
const mockProject = {
projectObservationFields: [{
required: true,
obsField: {
allowedValues: [],
id: 10,
},
}],
};
const mockObservation = {
observationFieldValues: [{ obsFieldId: 10, value }],
};
const result = validateProjectFieldsForObservation( mockObservation, [mockProject] );
expect( result.valid ).toBe( false );
expect( result.errors[0].reason ).toBe( MISSING_REQUIRED );
} );
it( "should be valid when a required field's OFV is non-empty after trim", () => {
const mockProject = {
projectObservationFields: [{
required: true,
obsField: {
allowedValues: [],
id: 10,
},
}],
};
const mockObservation = {
observationFieldValues: [{
obsFieldId: 10,
value: "something",
}],
};
expect(
validateProjectFieldsForObservation( mockObservation, [mockProject] ).valid,
).toBe( true );
} );
it( "should be valid when an optional field has no OFV", () => {
const mockProject = {
projectObservationFields: [{
required: false,
obsField: {
allowedValues: [],
id: 10,
},
}],
};
const mockObservation = { observationFieldValues: [] };
expect(
validateProjectFieldsForObservation( mockObservation, [mockProject] ).valid,
).toBe( true );
} );
it( "should report only the unfilled field when one of two required fields is filled", () => {
const mockProject = {
projectObservationFields: [
{
required: true,
obsField: {
allowedValues: [],
id: 10,
name: "Habitat",
},
},
{
required: true,
obsField: {
allowedValues: [],
id: 20,
name: "Substrate",
},
},
],
};
const mockObservation = {
observationFieldValues: [{ obsFieldId: 10, value: "shrubland" }],
};
const result = validateProjectFieldsForObservation( mockObservation, [mockProject] );
expect( result.valid ).toBe( false );
expect( result.errors ).toHaveLength( 1 );
expect( result.errors[0].fieldName ).toBe( "Substrate" );
expect( result.errors[0].reason ).toBe( MISSING_REQUIRED );
} );
it( "should report both fields in POF order when two required fields are unfilled", () => {
const mockProject = {
projectObservationFields: [
{
required: true,
obsField: {
allowedValues: [],
id: 10,
name: "Habitat",
},
},
{
required: true,
obsField: {
allowedValues: [],
id: 20,
name: "Substrate",
},
},
],
};
const mockObservation = { observationFieldValues: [] };
const result = validateProjectFieldsForObservation( mockObservation, [mockProject] );
expect( result.valid ).toBe( false );
expect( result.errors.map( e => e.fieldName ) ).toEqual( ["Habitat", "Substrate"] );
} );
} );
describe( "numeric fields", () => {
test.each( [
["12.5"],
["42"],
["-3.2"],
[" 7 "],
] )( "should be valid when a required numeric field's OFV value is %p", value => {
const mockProject = {
projectObservationFields: [{
required: true,
obsField: {
allowedValues: [],
datatype: "numeric",
id: 10,
},
}],
};
const mockObservation = {
observationFieldValues: [{ obsFieldId: 10, value }],
};
expect(
validateProjectFieldsForObservation( mockObservation, [mockProject] ).valid,
).toBe( true );
} );
test.each( [
["abc"],
// parseFloat would accept this, Number does not; must stay invalid
// to match Android Legacy's Float.valueOf
["1.5abc"],
] )( "should return INVALID_NUMERIC when a numeric field's OFV value is %p", value => {
const mockProject = {
projectObservationFields: [{
required: true,
obsField: {
allowedValues: [],
datatype: "numeric",
id: 10,
name: "Count",
},
}],
};
const mockObservation = {
observationFieldValues: [{ obsFieldId: 10, value }],
};
const result = validateProjectFieldsForObservation( mockObservation, [mockProject] );
expect( result.valid ).toBe( false );
expect( result.errors ).toHaveLength( 1 );
expect( result.errors[0].fieldName ).toBe( "Count" );
expect( result.errors[0].reason ).toBe( INVALID_NUMERIC );
} );
it( "should return INVALID_NUMERIC for an optional numeric field with text value", () => {
const mockProject = {
projectObservationFields: [{
required: false,
obsField: {
allowedValues: [],
datatype: "numeric",
id: 10,
},
}],
};
const mockObservation = {
observationFieldValues: [{ obsFieldId: 10, value: "abc" }],
};
const result = validateProjectFieldsForObservation( mockObservation, [mockProject] );
expect( result.valid ).toBe( false );
expect( result.errors[0].reason ).toBe( INVALID_NUMERIC );
} );
test.each( [
[[]],
[[{ obsFieldId: 10, value: "" }]],
] )( "should be valid for an optional numeric field with OFVs %p", observationFieldValues => {
const mockProject = {
projectObservationFields: [{
required: false,
obsField: {
allowedValues: [],
datatype: "numeric",
id: 10,
},
}],
};
const mockObservation = { observationFieldValues };
expect(
validateProjectFieldsForObservation( mockObservation, [mockProject] ).valid,
).toBe( true );
} );
it( "should report a single MISSING_REQUIRED when a required numeric field is empty", () => {
const mockProject = {
projectObservationFields: [{
required: true,
obsField: {
allowedValues: [],
datatype: "numeric",
id: 10,
},
}],
};
const mockObservation = { observationFieldValues: [] };
const result = validateProjectFieldsForObservation( mockObservation, [mockProject] );
expect( result.errors ).toHaveLength( 1 );
expect( result.errors[0].reason ).toBe( MISSING_REQUIRED );
} );
} );
describe( "multiple projects", () => {
it( "should be valid when one OFV satisfies two projects requiring the same field", () => {
const mockProjectA = {
id: 1,
title: "Project A",
projectObservationFields: [{
required: true,
obsField: {
allowedValues: [],
id: 10,
name: "Habitat",
},
}],
};
const mockProjectB = {
id: 2,
title: "Project B",
projectObservationFields: [{
required: true,
obsField: {
allowedValues: [],
id: 10,
name: "Habitat",
},
}],
};
const mockObservation = {
observationFieldValues: [{ obsFieldId: 10, value: "shrubland" }],
};
const result = validateProjectFieldsForObservation(
mockObservation,
[mockProjectA, mockProjectB],
);
expect( result.valid ).toBe( true );
expect( result.errors ).toEqual( [] );
} );
it( "should report one error per project when a shared required field is unfilled", () => {
const mockProjectA = {
id: 1,
title: "Project A",
projectObservationFields: [{
required: true,
obsField: {
allowedValues: [],
id: 10,
name: "Habitat",
},
}],
};
const mockProjectB = {
id: 2,
title: "Project B",
projectObservationFields: [{
required: true,
obsField: {
allowedValues: [],
id: 10,
name: "Habitat",
},
}],
};
const mockObservation = { observationFieldValues: [] };
const result = validateProjectFieldsForObservation(
mockObservation,
[mockProjectA, mockProjectB],
);
expect( result.valid ).toBe( false );
expect( result.errors ).toHaveLength( 2 );
expect( result.errors.map( e => e.projectTitle ) ).toEqual( ["Project A", "Project B"] );
expect( result.errors.map( e => e.fieldName ) ).toEqual( ["Habitat", "Habitat"] );
} );
it( "should report only the failing project when the other is satisfied", () => {
const mockProjectA = {
id: 1,
title: "Project A",
projectObservationFields: [{
required: true,
obsField: {
allowedValues: [],
id: 10,
name: "Habitat",
},
}],
};
const mockProjectB = {
id: 2,
title: "Project B",
projectObservationFields: [{
required: true,
obsField: {
allowedValues: [],
id: 20,
name: "Substrate",
},
}],
};
const mockObservation = {
observationFieldValues: [{ obsFieldId: 10, value: "shrubland" }],
};
const result = validateProjectFieldsForObservation(
mockObservation,
[mockProjectA, mockProjectB],
);
expect( result.errors ).toHaveLength( 1 );
expect( result.errors[0].projectTitle ).toBe( "Project B" );
expect( result.errors[0].fieldName ).toBe( "Substrate" );
} );
it( "should report errors in the order the projects were passed in", () => {
const mockProjectA = {
id: 1,
title: "Project A",
projectObservationFields: [{
required: true,
obsField: {
allowedValues: [],
id: 10,
name: "Habitat",
},
}],
};
const mockProjectB = {
id: 2,
title: "Project B",
projectObservationFields: [{
required: true,
obsField: {
allowedValues: [],
id: 20,
name: "Substrate",
},
}],
};
const mockObservation = { observationFieldValues: [] };
const resultBA = validateProjectFieldsForObservation(
mockObservation,
[mockProjectB, mockProjectA],
);
expect( resultBA.errors.map( e => e.projectTitle ) ).toEqual( ["Project B", "Project A"] );
} );
} );
describe( "edge cases", () => {
it( "should be valid when no projects are passed", () => {
const mockObservation = { observationFieldValues: [] };
const result = validateProjectFieldsForObservation( mockObservation, [] );
expect( result.valid ).toBe( true );
expect( result.errors ).toEqual( [] );
} );
it( "should be valid when a project has no POFs", () => {
const mockProject = { projectObservationFields: [] };
const mockObservation = { observationFieldValues: [] };
expect(
validateProjectFieldsForObservation( mockObservation, [mockProject] ).valid,
).toBe( true );
} );
it( "should skip a POF without an obsField definition", () => {
const mockProject = {
projectObservationFields: [{
required: true,
obsField: undefined,
}],
};
const mockObservation = { observationFieldValues: [] };
expect(
validateProjectFieldsForObservation( mockObservation, [mockProject] ).valid,
).toBe( true );
} );
} );
} );

View File

@@ -1,22 +1,15 @@
import {
defaultExploreV2Location,
EXPLORE_V2_ACTION,
EXPLORE_V2_PLACE_MODE,
exploreV2Reducer,
initialExploreV2State,
} from "providers/ExploreV2Context";
import fetchCoarseUserLocation from "sharedHelpers/fetchCoarseUserLocation";
import { OBSERVATIONS_SORT } from "sharedHelpers/observationsSort";
jest.mock( "sharedHelpers/fetchCoarseUserLocation", ( ) => ( {
__esModule: true,
default: jest.fn( ),
} ) );
describe( "initialExploreV2State", ( ) => {
it( "starts with no subject, UNINITIALIZED placeMode, newest-upload sort, empty filters", ( ) => {
it( "starts with no subject, NEARBY placeMode, newest-upload sort, empty filters", ( ) => {
expect( initialExploreV2State.subject ).toBeNull( );
expect( initialExploreV2State.location.placeMode ).toBe( EXPLORE_V2_PLACE_MODE.UNINITIALIZED );
expect( initialExploreV2State.location.placeMode ).toBe( EXPLORE_V2_PLACE_MODE.NEARBY );
expect( initialExploreV2State.sortBy ).toBe( OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST );
expect( initialExploreV2State.filters ).toEqual( {} );
} );
@@ -40,12 +33,7 @@ describe( "exploreV2Reducer", ( ) => {
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,
},
location: { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY },
sortBy: OBSERVATIONS_SORT.MOST_FAVED,
filters: { quality_grade: "research" },
};
@@ -71,7 +59,7 @@ describe( "exploreV2Reducer", ( ) => {
} );
describe( "location actions", ( ) => {
it( "SET_LOCATION_NEARBY transitions from PLACE and drops place", ( ) => {
it( "SET_LOCATION_NEARBY transitions from PLACE and drops place (no coords stored)", ( ) => {
const state = {
...initialExploreV2State,
location: {
@@ -81,45 +69,28 @@ describe( "exploreV2Reducer", ( ) => {
};
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.lat ).toBeUndefined( );
expect( next.location.place ).toBeUndefined( );
} );
it( "SET_LOCATION_WORLDWIDE transitions from NEARBY and drops coords", ( ) => {
it( "SET_LOCATION_WORLDWIDE transitions from NEARBY", ( ) => {
const state = {
...initialExploreV2State,
location: {
placeMode: EXPLORE_V2_PLACE_MODE.NEARBY,
lat: 1,
lng: 1,
radius: 1,
},
location: { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY },
};
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", ( ) => {
it( "SET_LOCATION_PLACE transitions from NEARBY", ( ) => {
const state = {
...initialExploreV2State,
location: {
placeMode: EXPLORE_V2_PLACE_MODE.NEARBY,
lat: 1,
lng: 1,
radius: 1,
},
location: { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY },
};
const place = { id: 5, display_name: "Oakland" };
const next = exploreV2Reducer( state, {
@@ -128,7 +99,6 @@ describe( "exploreV2Reducer", ( ) => {
} );
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", ( ) => {
@@ -148,32 +118,10 @@ describe( "exploreV2Reducer", ( ) => {
expect( next.location.place ).toEqual( place );
} );
it( "SET_LOCATION_NEEDS_PERMISSION transitions from UNINITIALIZED", ( ) => {
const next = exploreV2Reducer( initialExploreV2State, {
type: EXPLORE_V2_ACTION.SET_LOCATION_NEEDS_PERMISSION,
} );
expect( next.location.placeMode ).toBe( EXPLORE_V2_PLACE_MODE.NEEDS_PERMISSION );
} );
it( "SET_LOCATION_NEEDS_PERMISSION preserves subject, sortBy, and filters", ( ) => {
const state = {
subject: { type: "taxon", taxon: { id: 42 } },
location: { placeMode: EXPLORE_V2_PLACE_MODE.UNINITIALIZED },
sortBy: OBSERVATIONS_SORT.MOST_FAVED,
filters: { quality_grade: "research" },
};
const next = exploreV2Reducer( state, {
type: EXPLORE_V2_ACTION.SET_LOCATION_NEEDS_PERMISSION,
} );
expect( next.subject ).toEqual( state.subject );
expect( next.sortBy ).toBe( state.sortBy );
expect( next.filters ).toEqual( state.filters );
} );
it( "preserves subject, sortBy, and filters when changing location", ( ) => {
const state = {
subject: { type: "taxon", taxon: { id: 42 } },
location: { placeMode: EXPLORE_V2_PLACE_MODE.UNINITIALIZED },
location: { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY },
sortBy: OBSERVATIONS_SORT.MOST_FAVED,
filters: { quality_grade: "research" },
};
@@ -223,26 +171,3 @@ describe( "exploreV2Reducer", ( ) => {
} );
} );
} );
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 } );
} );
} );