mirror of
https://github.com/inaturalist/iNaturalistReactNative.git
synced 2026-08-02 18:37:06 -04:00
merge main
This commit is contained in:
15
.github/workflows/e2e_android.yml
vendored
15
.github/workflows/e2e_android.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/e2e_ios.yml
vendored
4
.github/workflows/e2e_ios.yml
vendored
@@ -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 }}
|
||||
|
||||
Binary file not shown.
@@ -3,7 +3,7 @@
|
||||
"data": [
|
||||
{
|
||||
"path": "assets/fonts/INatIcon.ttf",
|
||||
"sha1": "89aa6f39308fa5bad4edbdd509983df51f06f30b"
|
||||
"sha1": "35ca4fba9e8a1542c0ee4a06d4b5035627d92cbb"
|
||||
},
|
||||
{
|
||||
"path": "assets/fonts/Lato-Bold.ttf",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
6
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
9
src/api/types.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
} ) }
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" )}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}` );
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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" );
|
||||
|
||||
@@ -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,
|
||||
} );
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
84
src/components/Journal/UserPosts.tsx
Normal file
84
src/components/Journal/UserPosts.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 ) && (
|
||||
<>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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( "" );
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
} );
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
106
src/sharedHelpers/validateProjectFieldsForObservation.ts
Normal file
106
src/sharedHelpers/validateProjectFieldsForObservation.ts
Normal 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 };
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -89,7 +89,7 @@ interface ObservationFlowActions {
|
||||
setRollbackSnapshot: ( ) => void;
|
||||
}
|
||||
|
||||
type ObservationFlowSlice = ObservationFlowState & ObservationFlowActions;
|
||||
export type ObservationFlowSlice = ObservationFlowState & ObservationFlowActions;
|
||||
|
||||
const DEFAULT_STATE: ObservationFlowState = {
|
||||
aICameraSuggestion: null,
|
||||
|
||||
@@ -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,
|
||||
} ) );
|
||||
|
||||
@@ -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,
|
||||
} ) );
|
||||
|
||||
@@ -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" );
|
||||
} );
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -50,7 +50,7 @@ const StateProbe = () => {
|
||||
const actor = userEvent.setup( );
|
||||
|
||||
const renderGridItem = ( props = {} ) => renderComponent(
|
||||
<ExploreV2Provider requestLocationPermissions={() => {}}>
|
||||
<ExploreV2Provider>
|
||||
<ExploreV2SpeciesGridItem taxon={mockTaxon} {...props} />
|
||||
<StateProbe />
|
||||
</ExploreV2Provider>,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { renderComponent } from "tests/helpers/render";
|
||||
const actor = userEvent.setup( );
|
||||
|
||||
const renderTabs = props => renderComponent(
|
||||
<ExploreV2Provider requestLocationPermissions={() => {}}>
|
||||
<ExploreV2Provider>
|
||||
<ExploreV2Tabs {...props} />
|
||||
</ExploreV2Provider>,
|
||||
);
|
||||
|
||||
@@ -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( );
|
||||
} );
|
||||
} );
|
||||
@@ -72,7 +72,7 @@ jest.mock( "@tanstack/react-query", () => {
|
||||
} );
|
||||
|
||||
const renderView = ( props = {} ) => renderComponent(
|
||||
<ExploreV2Provider requestLocationPermissions={() => {}}>
|
||||
<ExploreV2Provider>
|
||||
<ExploreV2SpeciesView
|
||||
enabled
|
||||
isConnected
|
||||
|
||||
@@ -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" } );
|
||||
},
|
||||
);
|
||||
} );
|
||||
|
||||
@@ -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", ( ) => {
|
||||
|
||||
@@ -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 } ),
|
||||
);
|
||||
} );
|
||||
} );
|
||||
|
||||
@@ -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( );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
||||
533
tests/unit/helpers/validateProjectFieldsForObservation.test.js
Normal file
533
tests/unit/helpers/validateProjectFieldsForObservation.test.js
Normal 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 );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
@@ -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 } );
|
||||
} );
|
||||
} );
|
||||
|
||||
Reference in New Issue
Block a user