diff --git a/src/components/Explore/ExploreV2/components/ExploreV2Header.tsx b/src/components/Explore/ExploreV2/components/ExploreV2Header.tsx index 7c4ad036ef..9d77c19be7 100644 --- a/src/components/Explore/ExploreV2/components/ExploreV2Header.tsx +++ b/src/components/Explore/ExploreV2/components/ExploreV2Header.tsx @@ -1,5 +1,6 @@ import { useNavigation } from "@react-navigation/native"; import { THUMBNAIL_CLASS } from "appConstants/classNames"; +import locationLabel from "components/Explore/ExploreV2/helpers/locationLabel"; import { Body1, Body3, @@ -14,11 +15,8 @@ import DisplayTaxonName from "components/SharedComponents/DisplayTaxonName"; import { Image, Pressable, View } from "components/styledComponents"; import type { TFunction } from "i18next"; import type { ExploreStackScreenProps } from "navigation/types"; -import type { ExploreV2LocationState, ExploreV2Subject } from "providers/ExploreV2Context"; -import { - EXPLORE_V2_PLACE_MODE, - useExploreV2, -} from "providers/ExploreV2Context"; +import type { ExploreV2Subject } from "providers/ExploreV2Context"; +import { useExploreV2 } from "providers/ExploreV2Context"; import React from "react"; import { useCurrentUser, useTranslation } from "sharedHooks"; import colors from "styles/tailwindColors"; @@ -41,21 +39,6 @@ function subjectLabel( subject: ExploreV2Subject | null, t: TFunction ): string } } -function locationLabel( location: ExploreV2LocationState, t: TFunction ): string { - switch ( location.placeMode ) { - case EXPLORE_V2_PLACE_MODE.WORLDWIDE: - return t( "Worldwide" ); - case EXPLORE_V2_PLACE_MODE.NEARBY: - return t( "Nearby" ); - case EXPLORE_V2_PLACE_MODE.PLACE: - return location.place.display_name || ""; - case EXPLORE_V2_PLACE_MODE.MAP_AREA: - return t( "Map-Area" ); - default: - return ""; - } -} - const SubjectThumbnail = ( { subject }: { subject: ExploreV2Subject } ) => { switch ( subject.type ) { case "taxon": { diff --git a/src/components/Explore/ExploreV2/helpers/advancedSearchOptions.ts b/src/components/Explore/ExploreV2/helpers/advancedSearchOptions.ts new file mode 100644 index 0000000000..69fc4c11ff --- /dev/null +++ b/src/components/Explore/ExploreV2/helpers/advancedSearchOptions.ts @@ -0,0 +1,186 @@ +import type { TFunction } from "i18next"; +import { + DATE_OBSERVED, + DATE_UPLOADED, + ESTABLISHMENT_MEAN, + MEDIA, + PHOTO_LICENSE, + REVIEWED, + TAXONOMIC_RANK, + WILD_STATUS, +} from "providers/ExploreContext"; +import { OBSERVATIONS_SORT } from "sharedHelpers/observationsSort"; + +export interface FilterOption { + label: string; + value: ValueT; +} +const option = ( + value: ValueT, + label: string, +): [ValueT, FilterOption] => [value, { label, value }]; + +// `label` / `text` are the two lines of each row in the sort sheet; `labelCaps` +// is the single-line, all-caps version in the dropdown button +export const getSortByValues = ( t: TFunction ) => ( { + [OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST]: { + label: t( "Date-uploaded" ), + labelCaps: t( "DATE-UPLOADED-NEWEST" ), + text: t( "Newest-to-oldest" ), + value: OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST, + }, + [OBSERVATIONS_SORT.DATE_UPLOADED_OLDEST]: { + label: t( "Date-uploaded" ), + labelCaps: t( "DATE-UPLOADED-OLDEST" ), + text: t( "Oldest-to-newest" ), + value: OBSERVATIONS_SORT.DATE_UPLOADED_OLDEST, + }, + [OBSERVATIONS_SORT.DATE_OBSERVED_NEWEST]: { + label: t( "Date-observed" ), + labelCaps: t( "DATE-OBSERVED-NEWEST" ), + text: t( "Newest-to-oldest" ), + value: OBSERVATIONS_SORT.DATE_OBSERVED_NEWEST, + }, + [OBSERVATIONS_SORT.DATE_OBSERVED_OLDEST]: { + label: t( "Date-observed" ), + labelCaps: t( "DATE-OBSERVED-OLDEST" ), + text: t( "Oldest-to-newest" ), + value: OBSERVATIONS_SORT.DATE_OBSERVED_OLDEST, + }, +} ); + +export const getTaxonomicRankValues = ( t: TFunction ) => Object.fromEntries( [ + option( TAXONOMIC_RANK.none, t( "NONE--ranks" ) ), + option( TAXONOMIC_RANK.kingdom, t( "Ranks-KINGDOM" ) ), + option( TAXONOMIC_RANK.phylum, t( "Ranks-PHYLUM" ) ), + option( TAXONOMIC_RANK.subphylum, t( "Ranks-SUBPHYLUM" ) ), + option( TAXONOMIC_RANK.superclass, t( "Ranks-SUPERCLASS" ) ), + option( TAXONOMIC_RANK.class, t( "Ranks-CLASS" ) ), + option( TAXONOMIC_RANK.subclass, t( "Ranks-SUBCLASS" ) ), + option( TAXONOMIC_RANK.infraclass, t( "Ranks-INFRACLASS" ) ), + option( TAXONOMIC_RANK.subterclass, t( "Ranks-SUBTERCLASS" ) ), + option( TAXONOMIC_RANK.superorder, t( "Ranks-SUPERORDER" ) ), + option( TAXONOMIC_RANK.order, t( "Ranks-ORDER" ) ), + option( TAXONOMIC_RANK.suborder, t( "Ranks-SUBORDER" ) ), + option( TAXONOMIC_RANK.infraorder, t( "Ranks-INFRAORDER" ) ), + option( TAXONOMIC_RANK.parvorder, t( "Ranks-PARVORDER" ) ), + option( TAXONOMIC_RANK.zoosection, t( "Ranks-ZOOSECTION" ) ), + option( TAXONOMIC_RANK.zoosubsection, t( "Ranks-ZOOSUBSECTION" ) ), + option( TAXONOMIC_RANK.superfamily, t( "Ranks-SUPERFAMILY" ) ), + option( TAXONOMIC_RANK.epifamily, t( "Ranks-EPIFAMILY" ) ), + option( TAXONOMIC_RANK.family, t( "Ranks-FAMILY" ) ), + option( TAXONOMIC_RANK.subfamily, t( "Ranks-SUBFAMILY" ) ), + option( TAXONOMIC_RANK.supertribe, t( "Ranks-SUPERTRIBE" ) ), + option( TAXONOMIC_RANK.tribe, t( "Ranks-TRIBE" ) ), + option( TAXONOMIC_RANK.subtribe, t( "Ranks-SUBTRIBE" ) ), + option( TAXONOMIC_RANK.genus, t( "Ranks-GENUS" ) ), + option( TAXONOMIC_RANK.genushybrid, t( "Ranks-GENUSHYBRID" ) ), + option( TAXONOMIC_RANK.subgenus, t( "Ranks-SUBGENUS" ) ), + option( TAXONOMIC_RANK.section, t( "Ranks-SECTION" ) ), + option( TAXONOMIC_RANK.subsection, t( "Ranks-SUBSECTION" ) ), + option( TAXONOMIC_RANK.complex, t( "Ranks-COMPLEX" ) ), + option( TAXONOMIC_RANK.species, t( "Ranks-SPECIES" ) ), + option( TAXONOMIC_RANK.hybrid, t( "Ranks-HYBRID" ) ), + option( TAXONOMIC_RANK.subspecies, t( "Ranks-SUBSPECIES" ) ), + option( TAXONOMIC_RANK.variety, t( "Ranks-VARIETY" ) ), + option( TAXONOMIC_RANK.form, t( "Ranks-FORM" ) ), + option( TAXONOMIC_RANK.infrahybrid, t( "Ranks-INFRAHYBRID" ) ), +] ); + +export const getDateObservedValues = ( t: TFunction ) => ( { + [DATE_OBSERVED.ALL]: { + label: t( "All" ), + labelCaps: t( "ALL" ), + value: DATE_OBSERVED.ALL, + }, + [DATE_OBSERVED.EXACT_DATE]: { + label: t( "Exact-Date" ), + labelCaps: t( "EXACT-DATE" ), + text: t( "Filter-by-observed-on-date" ), + value: DATE_OBSERVED.EXACT_DATE, + }, + [DATE_OBSERVED.DATE_RANGE]: { + label: t( "Date-Range" ), + labelCaps: t( "DATE-RANGE" ), + text: t( "Filter-by-observed-between-dates" ), + value: DATE_OBSERVED.DATE_RANGE, + }, + [DATE_OBSERVED.MONTHS]: { + label: t( "Months" ), + labelCaps: t( "MONTHS" ), + text: t( "Filter-by-observed-during-months" ), + value: DATE_OBSERVED.MONTHS, + }, +} ); + +export const getDateUploadedValues = ( t: TFunction ) => ( { + [DATE_UPLOADED.ALL]: { + label: t( "All" ), + labelCaps: t( "ALL" ), + value: DATE_UPLOADED.ALL, + }, + [DATE_UPLOADED.EXACT_DATE]: { + label: t( "Exact-Date" ), + labelCaps: t( "EXACT-DATE" ), + text: t( "Filter-by-uploaded-on-date" ), + value: DATE_UPLOADED.EXACT_DATE, + }, + [DATE_UPLOADED.DATE_RANGE]: { + label: t( "Date-Range" ), + labelCaps: t( "DATE-RANGE" ), + text: t( "Filter-by-uploaded-between-dates" ), + value: DATE_UPLOADED.DATE_RANGE, + }, +} ); + +export const getMonthValues = ( t: TFunction ) => Object.fromEntries( [ + option( 1, t( "January" ) ), + option( 2, t( "February" ) ), + option( 3, t( "March" ) ), + option( 4, t( "April" ) ), + option( 5, t( "May" ) ), + option( 6, t( "June" ) ), + option( 7, t( "July" ) ), + option( 8, t( "August" ) ), + option( 9, t( "September" ) ), + option( 10, t( "October" ) ), + option( 11, t( "November" ) ), + option( 12, t( "December" ) ), +] ); + +export const getMediaValues = ( t: TFunction ) => Object.fromEntries( [ + option( MEDIA.ALL, t( "All" ) ), + option( MEDIA.PHOTOS, t( "Photos" ) ), + option( MEDIA.SOUNDS, t( "Sounds" ) ), + option( MEDIA.NONE, t( "No-Media" ) ), +] ); + +export const getEstablishmentValues = ( t: TFunction ) => Object.fromEntries( [ + option( ESTABLISHMENT_MEAN.ANY, t( "Any--establishment-means" ) ), + option( ESTABLISHMENT_MEAN.INTRODUCED, t( "Introduced" ) ), + option( ESTABLISHMENT_MEAN.NATIVE, t( "Native" ) ), + option( ESTABLISHMENT_MEAN.ENDEMIC, t( "Endemic" ) ), +] ); + +export const getWildValues = ( t: TFunction ) => Object.fromEntries( [ + option( WILD_STATUS.ALL, t( "All" ) ), + option( WILD_STATUS.WILD, t( "Wild" ) ), + option( WILD_STATUS.CAPTIVE, t( "Captive-Cultivated" ) ), +] ); + +export const getReviewedValues = ( t: TFunction ) => Object.fromEntries( [ + option( REVIEWED.ALL, t( "All-observations" ) ), + option( REVIEWED.REVIEWED, t( "Reviewed-observations-only" ) ), + option( REVIEWED.UNREVIEWED, t( "Unreviewed-observations-only" ) ), +] ); + +export const getPhotoLicenseValues = ( t: TFunction ) => Object.fromEntries( [ + option( PHOTO_LICENSE.ALL, t( "ALL" ) ), + option( PHOTO_LICENSE.CC0, t( "CC0" ) ), + option( PHOTO_LICENSE.CCBY, t( "CC-BY" ) ), + option( PHOTO_LICENSE.CCBYNC, t( "CC-BY-NC" ) ), + option( PHOTO_LICENSE.CCBYSA, t( "CC-BY-SA" ) ), + option( PHOTO_LICENSE.CCBYND, t( "CC-BY-ND" ) ), + option( PHOTO_LICENSE.CCBYNCSA, t( "CC-BY-NC-SA" ) ), + option( PHOTO_LICENSE.CCBYNCND, t( "CC-BY-NC-ND" ) ), +] ); diff --git a/src/components/Explore/ExploreV2/helpers/advancedSearchReducer.ts b/src/components/Explore/ExploreV2/helpers/advancedSearchReducer.ts new file mode 100644 index 0000000000..a5f382bf7b --- /dev/null +++ b/src/components/Explore/ExploreV2/helpers/advancedSearchReducer.ts @@ -0,0 +1,234 @@ +import type { ApiProjectSummary, ApiUser } from "api/types"; +import type { + ESTABLISHMENT_MEAN, + MEDIA, + PHOTO_LICENSE, + REVIEWED, +} from "providers/ExploreContext"; +import { + DATE_OBSERVED, + DATE_UPLOADED, + TAXONOMIC_RANK, + WILD_STATUS, +} from "providers/ExploreContext"; +import type { + ExploreV2Filters, + ExploreV2LocationState, + ExploreV2State, + ExploreV2Subject, +} from "providers/ExploreV2Context"; +import { + defaultExploreV2Filters, + EXPLORE_V2_PLACE_MODE, +} from "providers/ExploreV2Context"; +import { OBSERVATIONS_SORT } from "sharedHelpers/observationsSort"; + +// Advanced search only keeps a taxon or "unknown" as a subject +export type AdvancedSearchSubject = Extract< + ExploreV2Subject, + { type: "taxon" | "unknown" } +>; + +// The draft is a subset of ExploreV2State +export interface AdvancedSearchDraft { + subject: AdvancedSearchSubject | null; + location: ExploreV2LocationState; + sortBy: OBSERVATIONS_SORT; + filters: ExploreV2Filters; +} + +// Only taxon goes in the subject. user/project live in the filter blob +export const draftFromV2State = ( v2: ExploreV2State ): AdvancedSearchDraft => { + const { subject } = v2; + const base = { location: v2.location, sortBy: v2.sortBy }; + switch ( subject?.type ) { + case "user": + return { ...base, subject: null, filters: { ...v2.filters, user: subject.user } }; + case "project": + return { ...base, subject: null, filters: { ...v2.filters, project: subject.project } }; + case "unobserved": + return { ...base, subject: null, filters: v2.filters }; + default: + return { ...base, subject: subject ?? null, filters: v2.filters }; + } +}; + +export const defaultAdvancedSearchDraft: AdvancedSearchDraft = { + subject: null, + location: { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE }, + sortBy: OBSERVATIONS_SORT.DATE_UPLOADED_NEWEST, + filters: defaultExploreV2Filters, +}; + +export type SubjectTaxon = Extract["taxon"]; + +export type AdvancedSearchAction = + | { type: "SET_TAXON"; taxon: SubjectTaxon | null } + | { type: "FILTER_BY_ICONIC_UNKNOWN" } + | { type: "SET_LOCATION_PLACE"; place: { id: number; display_name?: string } } + | { type: "SET_LOCATION_NEARBY" } + | { type: "SET_LOCATION_WORLDWIDE" } + | { type: "SET_SORT"; sortBy: OBSERVATIONS_SORT } + | { type: "SET_USER"; user: ApiUser | null } + | { type: "SET_EXCLUDE_USER"; user: ApiUser | null } + | { type: "SET_PROJECT"; project: ApiProjectSummary | null } + | { type: "TOGGLE_RESEARCH_GRADE" } + | { type: "TOGGLE_NEEDS_ID" } + | { type: "TOGGLE_CASUAL" } + | { type: "SET_HRANK"; hrank: TAXONOMIC_RANK } + | { type: "SET_LRANK"; lrank: TAXONOMIC_RANK } + | { type: "SET_DATE_OBSERVED_ALL" } + | { type: "SET_DATE_OBSERVED_EXACT"; observedOn: string } + | { type: "SET_DATE_OBSERVED_RANGE"; d1: string; d2: string } + | { type: "SET_DATE_OBSERVED_MONTHS"; months: number[] } + | { type: "SET_DATE_UPLOADED_ALL" } + | { type: "SET_DATE_UPLOADED_EXACT"; createdOn: string } + | { type: "SET_DATE_UPLOADED_RANGE"; createdD1: string; createdD2: string } + | { type: "SET_MEDIA"; media: MEDIA } + | { type: "SET_ESTABLISHMENT_MEAN"; establishmentMean: ESTABLISHMENT_MEAN } + | { type: "SET_WILD_STATUS"; wildStatus: WILD_STATUS } + | { type: "SET_REVIEWED"; reviewedFilter: REVIEWED } + | { type: "SET_PHOTO_LICENSE"; photoLicense: PHOTO_LICENSE } + | { type: "RESET" }; + +const withFilters = ( + draft: AdvancedSearchDraft, + patch: Partial, +): AdvancedSearchDraft => ( { + ...draft, + filters: { ...draft.filters, ...patch }, +} ); + +export const advancedSearchReducer = ( + draft: AdvancedSearchDraft, + action: AdvancedSearchAction, +): AdvancedSearchDraft => { + switch ( action.type ) { + case "SET_TAXON": + return { + ...draft, + subject: action.taxon + ? { type: "taxon", taxon: action.taxon } + : null, + }; + case "FILTER_BY_ICONIC_UNKNOWN": + // special case -- not a taxon + return { ...draft, subject: { type: "unknown" } }; + case "SET_LOCATION_PLACE": + return { + ...draft, + location: { placeMode: EXPLORE_V2_PLACE_MODE.PLACE, place: action.place }, + }; + case "SET_LOCATION_NEARBY": + return { + ...draft, + location: { placeMode: EXPLORE_V2_PLACE_MODE.NEARBY }, + }; + case "SET_LOCATION_WORLDWIDE": + return { + ...draft, + location: { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE }, + }; + case "SET_SORT": + return { ...draft, sortBy: action.sortBy }; + case "SET_USER": + return withFilters( draft, { user: action.user, excludeUser: null } ); + case "SET_EXCLUDE_USER": + return withFilters( draft, { excludeUser: action.user, user: null } ); + case "SET_PROJECT": + return withFilters( draft, { project: action.project } ); + case "TOGGLE_RESEARCH_GRADE": + return withFilters( draft, { researchGrade: !draft.filters.researchGrade } ); + case "TOGGLE_NEEDS_ID": + return withFilters( draft, { needsID: !draft.filters.needsID } ); + case "TOGGLE_CASUAL": + return withFilters( draft, { casual: !draft.filters.casual } ); + case "SET_HRANK": + return withFilters( draft, { + hrank: action.hrank === TAXONOMIC_RANK.none + ? null + : action.hrank, + } ); + case "SET_LRANK": + return withFilters( draft, { + lrank: action.lrank === TAXONOMIC_RANK.none + ? null + : action.lrank, + } ); + case "SET_DATE_OBSERVED_ALL": + return withFilters( draft, { + dateObserved: DATE_OBSERVED.ALL, + observed_on: null, + d1: null, + d2: null, + months: null, + } ); + case "SET_DATE_OBSERVED_EXACT": + return withFilters( draft, { + dateObserved: DATE_OBSERVED.EXACT_DATE, + observed_on: action.observedOn, + d1: null, + d2: null, + months: null, + } ); + case "SET_DATE_OBSERVED_RANGE": + return withFilters( draft, { + dateObserved: DATE_OBSERVED.DATE_RANGE, + observed_on: null, + d1: action.d1, + d2: action.d2, + months: null, + } ); + case "SET_DATE_OBSERVED_MONTHS": + return withFilters( draft, { + dateObserved: DATE_OBSERVED.MONTHS, + observed_on: null, + d1: null, + d2: null, + months: action.months, + } ); + case "SET_DATE_UPLOADED_ALL": + return withFilters( draft, { + dateUploaded: DATE_UPLOADED.ALL, + created_on: null, + created_d1: null, + created_d2: null, + } ); + case "SET_DATE_UPLOADED_EXACT": + return withFilters( draft, { + dateUploaded: DATE_UPLOADED.EXACT_DATE, + created_on: action.createdOn, + created_d1: null, + created_d2: null, + } ); + case "SET_DATE_UPLOADED_RANGE": + return withFilters( draft, { + dateUploaded: DATE_UPLOADED.DATE_RANGE, + created_on: null, + created_d1: action.createdD1, + created_d2: action.createdD2, + } ); + case "SET_MEDIA": + return withFilters( draft, { media: action.media } ); + case "SET_ESTABLISHMENT_MEAN": + return withFilters( draft, { establishmentMean: action.establishmentMean } ); + case "SET_WILD_STATUS": + return withFilters( draft, { + wildStatus: action.wildStatus, + casual: action.wildStatus === WILD_STATUS.CAPTIVE + ? true + : draft.filters.casual, + } ); + case "SET_REVIEWED": + return withFilters( draft, { reviewedFilter: action.reviewedFilter } ); + case "SET_PHOTO_LICENSE": + return withFilters( draft, { photoLicense: action.photoLicense } ); + case "RESET": + return defaultAdvancedSearchDraft; + default: { + // https://www.typescriptlang.org/docs/handbook/2/narrowing.html#exhaustiveness-checking + const _exhaustive: never = action; + return _exhaustive; + } + } +}; diff --git a/src/components/Explore/ExploreV2/helpers/buildQueryParams.ts b/src/components/Explore/ExploreV2/helpers/buildQueryParams.ts index 53254624d8..56718fb6c9 100644 --- a/src/components/Explore/ExploreV2/helpers/buildQueryParams.ts +++ b/src/components/Explore/ExploreV2/helpers/buildQueryParams.ts @@ -39,7 +39,7 @@ const buildExploreV2QueryParams = ( state: ExploreV2State, nearbyCoords?: NearbyCoords, // The signed-in user's id, needed by the reviewed filter (viewer_id param). - viewerId?: number | null, + currentUserId?: number | null, ): ExploreV2QueryParams => { const params: ExploreV2QueryParams = { per_page: PER_PAGE, @@ -97,7 +97,7 @@ const buildExploreV2QueryParams = ( } // Advanced Search filters - const filterParams = filtersToApiParams( state.filters, viewerId ); + const filterParams = filtersToApiParams( state.filters, currentUserId ); const paramsWithFilters: ExploreV2QueryParams = { ...params, ...filterParams, diff --git a/src/components/Explore/ExploreV2/helpers/filtersToApiParams.ts b/src/components/Explore/ExploreV2/helpers/filtersToApiParams.ts index 90d01ec1af..090d9f3cf1 100644 --- a/src/components/Explore/ExploreV2/helpers/filtersToApiParams.ts +++ b/src/components/Explore/ExploreV2/helpers/filtersToApiParams.ts @@ -28,7 +28,6 @@ export interface FilterApiParams { reviewed?: boolean; viewer_id?: number; photo_license?: string; - iconic_taxa?: string[]; user_id?: number; not_user_id?: number; project_id?: number; @@ -104,10 +103,6 @@ const filtersToApiParams = ( if ( license ) { params.photo_license = license; } } - if ( filters.iconic_taxa && filters.iconic_taxa.length > 0 ) { - params.iconic_taxa = filters.iconic_taxa; - } - if ( filters.user?.id ) { params.user_id = filters.user.id; } if ( filters.excludeUser?.id ) { diff --git a/src/components/Explore/ExploreV2/helpers/locationLabel.ts b/src/components/Explore/ExploreV2/helpers/locationLabel.ts new file mode 100644 index 0000000000..5579bf210e --- /dev/null +++ b/src/components/Explore/ExploreV2/helpers/locationLabel.ts @@ -0,0 +1,23 @@ +import type { TFunction } from "i18next"; +import type { ExploreV2LocationState } from "providers/ExploreV2Context"; +import { EXPLORE_V2_PLACE_MODE } from "providers/ExploreV2Context"; + +function locationLabel( location: ExploreV2LocationState, t: TFunction ): string { + switch ( location.placeMode ) { + case EXPLORE_V2_PLACE_MODE.WORLDWIDE: + return t( "Worldwide" ); + case EXPLORE_V2_PLACE_MODE.NEARBY: + return t( "Nearby" ); + case EXPLORE_V2_PLACE_MODE.PLACE: + return location.place.display_name || ""; + case EXPLORE_V2_PLACE_MODE.MAP_AREA: + return t( "Map-Area" ); + default: { + // Exhaustiveness check: ts fails if a new placeMode is added without a case. + const _exhaustive: never = location; + return _exhaustive; + } + } +} + +export default locationLabel; diff --git a/src/components/Explore/ExploreV2/helpers/universalSearchSubject.ts b/src/components/Explore/ExploreV2/helpers/universalSearchSubject.ts index 72b8108168..259731bfda 100644 --- a/src/components/Explore/ExploreV2/helpers/universalSearchSubject.ts +++ b/src/components/Explore/ExploreV2/helpers/universalSearchSubject.ts @@ -21,16 +21,13 @@ export const resultToSubject = ( result: UniversalSearchResultItem ): ExploreV2S id: result.user.id, login: result.user.login, icon_url: result.user.icon_url, + observations_count: result.user.observations_count, }, }; case "project": return { type: "project", - project: { - id: result.project.id, - title: result.project.title, - icon: result.project.icon, - }, + project: result.project, }; case "taxon": return { diff --git a/src/components/Explore/ExploreV2/screens/AdvancedSearch.tsx b/src/components/Explore/ExploreV2/screens/AdvancedSearch.tsx index 31cb9e3b95..0a64db388f 100644 --- a/src/components/Explore/ExploreV2/screens/AdvancedSearch.tsx +++ b/src/components/Explore/ExploreV2/screens/AdvancedSearch.tsx @@ -1,17 +1,786 @@ -import { Body2, ViewWrapper } from "components/SharedComponents"; -import { View } from "components/styledComponents"; -import React from "react"; -import { useTranslation } from "sharedHooks"; +import { useNavigation } from "@react-navigation/native"; +import type { ApiPlace, ApiProjectSummary } from "api/types"; +import { OBSERVATIONS_TAB } from "appConstants/tabs"; +import DateFilterSection + from "components/Explore/ExploreV2/components/DateFilterSection"; +import RadioGroupSection + from "components/Explore/ExploreV2/components/RadioGroupSection"; +import SelectedFilterRow + from "components/Explore/ExploreV2/components/SelectedFilterRow"; +import { + getDateObservedValues, + getDateUploadedValues, + getEstablishmentValues, + getMediaValues, + getMonthValues, + getPhotoLicenseValues, + getReviewedValues, + getSortByValues, + getTaxonomicRankValues, + getWildValues, +} from "components/Explore/ExploreV2/helpers/advancedSearchOptions"; +import type { SubjectTaxon } + from "components/Explore/ExploreV2/helpers/advancedSearchReducer"; +import { + advancedSearchReducer, + defaultAdvancedSearchDraft, + draftFromV2State, +} from "components/Explore/ExploreV2/helpers/advancedSearchReducer"; +import locationLabel from "components/Explore/ExploreV2/helpers/locationLabel"; +import ExploreLocationSearchModal from "components/Explore/Modals/ExploreLocationSearchModal"; +import ExploreProjectSearchModal from "components/Explore/Modals/ExploreProjectSearchModal"; +import ExploreTaxonSearchModal from "components/Explore/Modals/ExploreTaxonSearchModal"; +import ExploreUserSearchModal from "components/Explore/Modals/ExploreUserSearchModal"; +import type { ExploreSearchUser } + from "components/Explore/SearchScreens/ExploreUserSearch"; +import ProjectListItem from "components/ProjectList/ProjectListItem"; +import { + Body2, + Body3, + Button, + ButtonBar, + Checkbox, + DisplayTaxon, + Heading4, + IconicTaxonChooser, + INatIcon, + PickerSheet, + RadioButtonSheet, +} from "components/SharedComponents"; +import SearchHeader from "components/SharedComponents/SearchHeader"; +import WarningSheet from "components/SharedComponents/Sheets/WarningSheet"; +import { SharedStackViewWrapper } from "components/SharedComponents/ViewWrapper"; +import { ScrollView, View } from "components/styledComponents"; +import UserListItem from "components/UserList/UserListItem"; +import isEqual from "lodash/isEqual"; +import type { ExploreStackScreenProps } from "navigation/types"; +import { + DATE_OBSERVED, + DATE_UPLOADED, +} from "providers/ExploreContext"; +import { + EXPLORE_V2_ACTION, + EXPLORE_V2_PLACE_MODE, + useExploreV2, +} from "providers/ExploreV2Context"; +import React, { useMemo, useReducer, useState } from "react"; +import Taxon from "realmModels/Taxon"; +import { formatObsFieldDate } from "sharedHelpers/dateAndTime"; +import { useCurrentUser, useTranslation } from "sharedHooks"; +import useIconicTaxa from "sharedHooks/useIconicTaxa"; + +type SheetName = "sortBy" | "hrank" | "lrank" | "photoLicense"; + +type PickerKind = "taxon" | "user" | "project" | "location"; + +const ALL_MONTHS = new Array( 12 ).fill( 0 ).map( ( _, i ) => i + 1 ); + +const isoToday = ( ) => formatObsFieldDate( new Date( ) ); const AdvancedSearch = ( ) => { - const { t } = useTranslation( ); + const navigation + = useNavigation["navigation"]>(); + const { t } = useTranslation(); + const iconicTaxa = useIconicTaxa(); + const currentUser = useCurrentUser(); + + const { state: v2State, dispatch: dispatchV2 } = useExploreV2(); + const [initialDraft] = useState( ( ) => draftFromV2State( v2State ) ); + const [draft, dispatch] = useReducer( advancedSearchReducer, initialDraft ); + const { + subject, location, sortBy, filters, + } = draft; + + const differsFromInitial = useMemo( + ( ) => !isEqual( draft, initialDraft ), + [draft, initialDraft], + ); + + const resetDisabled = useMemo( + ( ) => isEqual( draft, defaultAdvancedSearchDraft ), + [draft], + ); + + const [showDiscardSheet, setShowDiscardSheet] = useState( false ); + const handleBack = ( ) => { + if ( differsFromInitial ) { + setShowDiscardSheet( true ); + return; + } + navigation.goBack( ); + }; + + // Which in-screen search picker (taxon/user/project/location) is open. + const [openPicker, setOpenPicker] = useState( null ); + const [openSheet, setOpenSheet] = useState( null ); + const closeSheet = ( ) => setOpenSheet( null ); + + const updateTaxon = ( taxon: SubjectTaxon | null ) => dispatch( { + type: "SET_TAXON", + taxon, + } ); + + const filterByIconicTaxonUnknown = () => dispatch( { + type: "FILTER_BY_ICONIC_UNKNOWN", + } ); + + const updateUser = ( selectedUser: ExploreSearchUser | null, exclude?: boolean ) => dispatch( + exclude + ? { type: "SET_EXCLUDE_USER", user: selectedUser } + : { type: "SET_USER", user: selectedUser }, + ); + + const updateProject = ( selectedProject: ApiProjectSummary | null ) => dispatch( { + type: "SET_PROJECT", + project: selectedProject, + } ); + + const updateLocation = ( newLocation: "worldwide" | ApiPlace ) => { + if ( newLocation === "worldwide" ) { + dispatch( { type: "SET_LOCATION_WORLDWIDE" } ); + return; + } + if ( !newLocation.id ) { return; } + dispatch( { + type: "SET_LOCATION_PLACE", + place: { id: newLocation.id, display_name: newLocation.display_name }, + } ); + }; + + const closePicker = ( ) => setOpenPicker( null ); + + const handleSearch = () => { + dispatchV2( + subject + ? { type: EXPLORE_V2_ACTION.SET_SUBJECT, subject } + : { type: EXPLORE_V2_ACTION.CLEAR_SUBJECT }, + ); + switch ( location.placeMode ) { + case EXPLORE_V2_PLACE_MODE.PLACE: + dispatchV2( { type: EXPLORE_V2_ACTION.SET_LOCATION_PLACE, place: location.place } ); + break; + case EXPLORE_V2_PLACE_MODE.WORLDWIDE: + dispatchV2( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } ); + break; + case EXPLORE_V2_PLACE_MODE.MAP_AREA: + dispatchV2( { + type: EXPLORE_V2_ACTION.SET_LOCATION_MAP_AREA, + bounds: location.bounds, + } ); + break; + case EXPLORE_V2_PLACE_MODE.NEARBY: + dispatchV2( { type: EXPLORE_V2_ACTION.SET_LOCATION_NEARBY } ); + break; + default: { + // Exhaustiveness check: ts fails if a new placeMode is added without a case. + const _exhaustive: never = location; + break; + } + } + dispatchV2( { type: EXPLORE_V2_ACTION.SET_SORT, sortBy } ); + dispatchV2( { type: EXPLORE_V2_ACTION.SET_FILTERS, filters } ); + dispatchV2( { type: EXPLORE_V2_ACTION.SET_ACTIVE_TAB, tab: OBSERVATIONS_TAB } ); + navigation.popTo( "ExploreResults" ); + }; + + const { + casual, + created_d1: createdD1, + created_d2: createdD2, + created_on: createdOn, + d1, + d2, + dateObserved, + dateUploaded, + establishmentMean, + excludeUser, + hrank, + lrank, + media, + months, + needsID, + observed_on: observedOn, + photoLicense, + project, + researchGrade, + reviewedFilter, + user, + wildStatus, + } = filters; + + const taxon = subject?.type === "taxon" + ? subject.taxon + : null; + const taxonUnknown = subject?.type === "unknown"; + const chosenIconicTaxa = ( ) => { + if ( taxonUnknown ) { return ["unknown"]; } + if ( taxon?.name ) { return [taxon.name.toLowerCase()]; } + return []; + }; + + const displayUser = user || excludeUser; + const displayProject = project; + + const sortByValues = getSortByValues( t ); + const taxonomicRankValues = getTaxonomicRankValues( t ); + const dateObservedValues = getDateObservedValues( t ); + const dateUploadedValues = getDateUploadedValues( t ); + const monthValues = getMonthValues( t ); + const photoLicenseValues = getPhotoLicenseValues( t ); + + const updateDateObserved = ( { + newDateObserved, newObservedOn, newD1, newD2, newMonths, + } ) => { + const today = isoToday( ); + + if ( newDateObserved === DATE_OBSERVED.ALL ) { + dispatch( { + type: "SET_DATE_OBSERVED_ALL", + } ); + } else if ( newDateObserved === DATE_OBSERVED.EXACT_DATE ) { + dispatch( { + type: "SET_DATE_OBSERVED_EXACT", + observedOn: newObservedOn || today, + } ); + } else if ( newDateObserved === DATE_OBSERVED.DATE_RANGE ) { + dispatch( { + type: "SET_DATE_OBSERVED_RANGE", + d1: newD1 || today, + d2: newD2 || today, + } ); + } else if ( newDateObserved === DATE_OBSERVED.MONTHS ) { + dispatch( { + type: "SET_DATE_OBSERVED_MONTHS", + months: newMonths || ALL_MONTHS, + } ); + } + }; + + const updateObservedMonths = monthInteger => { + const currentMonths = months || []; + const newMonths = currentMonths.includes( monthInteger ) + ? currentMonths.filter( m => m !== monthInteger ) + : [...currentMonths, monthInteger]; + updateDateObserved( { + newDateObserved: DATE_OBSERVED.MONTHS, + newMonths, + } ); + }; + + const updateDateUploaded = ( { newDateUploaded, newD1, newD2 } ) => { + const today = isoToday( ); + if ( newDateUploaded === DATE_UPLOADED.ALL ) { + dispatch( { + type: "SET_DATE_UPLOADED_ALL", + } ); + } else if ( newDateUploaded === DATE_UPLOADED.EXACT_DATE ) { + dispatch( { + type: "SET_DATE_UPLOADED_EXACT", + createdOn: newD1 || today, + } ); + } else if ( newDateUploaded === DATE_UPLOADED.DATE_RANGE ) { + dispatch( { + type: "SET_DATE_UPLOADED_RANGE", + createdD1: newD1 || today, + createdD2: newD2 || today, + } ); + } + }; + + const observedEndBeforeStart = d1 > d2; + const uploadedEndBeforeStart = createdD1 > createdD2; + // MONTHS mode with nothing checked emits no `month` param, which would + // silently run an unfiltered search; block Search until a month is selected. + const noMonthsSelected = dateObserved === DATE_OBSERVED.MONTHS + && ( months || [] ).length === 0; + const hasError = observedEndBeforeStart || uploadedEndBeforeStart || noMonthsSelected; return ( - - - {t( "ADVANCED-SEARCH" )} - - + + dispatch( { type: "RESET" } )} + resetDisabled={resetDisabled} + testID="AdvancedSearch.back" + /> + + + {/* Taxon Section */} + + {t( "TAXON" )} + + {( taxon || taxonUnknown ) + ? ( + setOpenPicker( "taxon" )} + onRemove={() => updateTaxon( null )} + removeAccessibilityLabel={t( "Remove-taxon-filter" )} + > + setOpenPicker( "taxon" )} + taxon={taxon || "unknown"} + /> + + ) + : ( +