From 2d44c9f654f7ca2532fef087c8d3a7be8aadeb73 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Tue, 23 Jun 2026 16:46:46 -0700 Subject: [PATCH 1/7] feat: carry taxa filters into occurrences and add Device & Site filters On the taxa list, clicking a row's occurrence or verified count opened the occurrence list showing every occurrence of that taxon, ignoring the station and verification filters that were active on the taxa list. Drilling in now carries those filters over so the occurrence list stays scoped to the same selection (station, session, device, site, verification, taxa list, default filters). The verified-count link still forces verified=true, since that is what the column represents. Both the taxa and occurrence lists also gain Device and Site filters, matching the existing Station filter. A deployment records a device (camera/hardware configuration) and a research site; users can now scope either list to one device or one site, e.g. to build a per-site presence matrix. Combined with the existing verification filter this supports the presence-verification workflow in issue #1320 (a quick preliminary step ahead of the larger Example-column work). Backend: deployment__device and deployment__research_site are added to the occurrence filterset, and the taxa view's get_occurrence_filters reads the same two params (restricting taxa membership, 404 on an unknown id). No model change, so no migration. Refs #1320 Co-Authored-By: Claude --- ami/main/api/views.py | 10 +++ ami/main/tests.py | 77 +++++++++++++++++++ .../components/filtering/filter-control.tsx | 4 + .../filtering/filters/device-filter.tsx | 17 ++++ .../filtering/filters/site-filter.tsx | 17 ++++ ui/src/pages/occurrences/occurrences.tsx | 11 ++- ui/src/pages/species/species-columns.tsx | 14 +++- ui/src/pages/species/species.tsx | 28 +++++++ ui/src/utils/getAppRoute.ts | 5 ++ ui/src/utils/useFilters.ts | 8 ++ 10 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 ui/src/components/filtering/filters/device-filter.tsx create mode 100644 ui/src/components/filtering/filters/site-filter.tsx diff --git a/ami/main/api/views.py b/ami/main/api/views.py index e44ee1b60..ab50f574a 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -1350,6 +1350,8 @@ def filter_queryset(self, request, queryset, view): OCCURRENCE_FILTERSET_FIELDS = ( "event", "deployment", + "deployment__device", + "deployment__research_site", "determination__rank", "detections__source_image", ) @@ -1744,6 +1746,8 @@ def get_occurrence_filters(self, project: Project, accessor: str = "") -> models deployment_id = self.request.query_params.get("deployment") or self.request.query_params.get( "occurrences__deployment" ) + device_id = self.request.query_params.get("deployment__device") + site_id = self.request.query_params.get("deployment__research_site") event_id = self.request.query_params.get("event") or self.request.query_params.get("occurrences__event") collection_id = self.request.query_params.get("collection") @@ -1765,6 +1769,12 @@ def field(path: str) -> str: if deployment_id: Deployment.objects.get(id=deployment_id) filters &= models.Q(**{field("deployment"): deployment_id}) + if device_id: + Device.objects.get(id=device_id) + filters &= models.Q(**{field("deployment__device"): device_id}) + if site_id: + Site.objects.get(id=site_id) + filters &= models.Q(**{field("deployment__research_site"): site_id}) if event_id: Event.objects.get(id=event_id) filters &= models.Q(**{field("event"): event_id}) diff --git a/ami/main/tests.py b/ami/main/tests.py index 4c8b4b219..10bf73c4d 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -6011,6 +6011,83 @@ def test_verified_count_not_inflated_by_collection_join(self): self.assertEqual(rows["Vanessa cardui"]["verified_count"], 2) +class TestDeviceAndSiteFilters(APITestCase): + """Filtering occurrences and taxa by the deployment's device and research site. + + A deployment records both a ``device`` (the camera/hardware configuration) and a + ``research_site``. Users can scope the occurrence and taxa lists to one device or + one site so they can, for example, build a presence matrix per site. These tests + pin that the occurrence list filters exactly to the chosen device/site, that the + taxa list restricts membership the same way (not just its annotated counts), and + that an unknown device/site id is rejected with a 404 on the taxa endpoint. + """ + + def setUp(self): + self.project, self.deployment_a = setup_test_project(reuse=False) + create_taxa(self.project) + self.cardui = Taxon.objects.get(name="Vanessa cardui") + self.atalanta = Taxon.objects.get(name="Vanessa atalanta") + + # Two devices and two sites, each pinned to its own deployment. + self.device_a = Device.objects.create(name="Device A", project=self.project) + self.device_b = Device.objects.create(name="Device B", project=self.project) + self.site_a = Site.objects.create(name="Site A", project=self.project) + self.site_b = Site.objects.create(name="Site B", project=self.project) + + self.deployment_a.device = self.device_a + self.deployment_a.research_site = self.site_a + self.deployment_a.save() + self.deployment_b = Deployment.objects.create( + name="Deployment B", + project=self.project, + device=self.device_b, + research_site=self.site_b, + ) + + # Deployment A only sees cardui; deployment B only sees atalanta. This lets the + # device/site filter be checked by both the occurrence count and the taxa membership. + create_captures(deployment=self.deployment_a, num_nights=1, images_per_night=2) + create_captures(deployment=self.deployment_b, num_nights=1, images_per_night=2) + create_occurrences(deployment=self.deployment_a, num=3, taxon=self.cardui, determination_score=0.9) + create_occurrences(deployment=self.deployment_b, num=2, taxon=self.atalanta, determination_score=0.9) + + def _occurrence_count(self, **params): + query = "&".join(f"{k}={v}" for k, v in params.items()) + res = self.client.get(f"/api/v2/occurrences/?project_id={self.project.pk}&{query}") + self.assertEqual(res.status_code, status.HTTP_200_OK) + return res.json()["count"] + + def _taxa_names(self, **params): + query = "&".join(f"{k}={v}" for k, v in params.items()) + res = self.client.get(f"/api/v2/taxa/?project_id={self.project.pk}&limit=1000&{query}") + self.assertEqual(res.status_code, status.HTTP_200_OK) + return {row["name"] for row in res.json()["results"]} + + def test_occurrences_filtered_by_device(self): + self.assertEqual(self._occurrence_count(), 5) + self.assertEqual(self._occurrence_count(deployment__device=self.device_a.pk), 3) + self.assertEqual(self._occurrence_count(deployment__device=self.device_b.pk), 2) + + def test_occurrences_filtered_by_site(self): + self.assertEqual(self._occurrence_count(deployment__research_site=self.site_a.pk), 3) + self.assertEqual(self._occurrence_count(deployment__research_site=self.site_b.pk), 2) + + def test_taxa_membership_restricted_by_device(self): + # The taxa list must drop to only the taxa observed on the chosen device, not + # merely re-scope the counts of the full taxa set. + self.assertEqual(self._taxa_names(deployment__device=self.device_a.pk), {"Vanessa cardui"}) + self.assertEqual(self._taxa_names(deployment__device=self.device_b.pk), {"Vanessa atalanta"}) + + def test_taxa_membership_restricted_by_site(self): + self.assertEqual(self._taxa_names(deployment__research_site=self.site_a.pk), {"Vanessa cardui"}) + self.assertEqual(self._taxa_names(deployment__research_site=self.site_b.pk), {"Vanessa atalanta"}) + + def test_unknown_device_or_site_returns_404_on_taxa(self): + for param in ("deployment__device", "deployment__research_site"): + res = self.client.get(f"/api/v2/taxa/?project_id={self.project.pk}&{param}=999999") + self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND, param) + + class TestDetectionNullMarker(TestCase): """ Covers the null-marker abstraction added for Issue #1310 follow-up: diff --git a/ui/src/components/filtering/filter-control.tsx b/ui/src/components/filtering/filter-control.tsx index 0e0944919..64dafd9cc 100644 --- a/ui/src/components/filtering/filter-control.tsx +++ b/ui/src/components/filtering/filter-control.tsx @@ -6,9 +6,11 @@ import { AlgorithmFilter, NotAlgorithmFilter } from './filters/algorithm-filter' import { BooleanFilter } from './filters/boolean-filter' import { CaptureSetFilter } from './filters/capture-set-filter' import { DateFilter } from './filters/date-filter' +import { DeviceFilter } from './filters/device-filter' import { ImageFilter } from './filters/image-filter' import { PipelineFilter } from './filters/pipeline-filter' import { SessionFilter } from './filters/session-filter' +import { SiteFilter } from './filters/site-filter' import { StationFilter } from './filters/station-filter' import { StatusFilter } from './filters/status-filter' import { TagFilter } from './filters/tag-filter' @@ -29,6 +31,8 @@ const ComponentMap: { date_end: DateFilter, date_start: DateFilter, deployment: StationFilter, + deployment__device: DeviceFilter, + deployment__research_site: SiteFilter, detections__source_image: ImageFilter, event: SessionFilter, processed: ProcessingStatusFilter, diff --git a/ui/src/components/filtering/filters/device-filter.tsx b/ui/src/components/filtering/filters/device-filter.tsx new file mode 100644 index 000000000..eb4b025a5 --- /dev/null +++ b/ui/src/components/filtering/filters/device-filter.tsx @@ -0,0 +1,17 @@ +import { API_ROUTES } from 'data-services/constants' +import { EntityPicker } from 'nova-ui-kit' +import { FilterProps } from './types' + +export const DeviceFilter = ({ onAdd, onClear, value }: FilterProps) => ( + { + if (value) { + onAdd(value) + } else { + onClear() + } + }} + value={value} + /> +) diff --git a/ui/src/components/filtering/filters/site-filter.tsx b/ui/src/components/filtering/filters/site-filter.tsx new file mode 100644 index 000000000..27cc7f05e --- /dev/null +++ b/ui/src/components/filtering/filters/site-filter.tsx @@ -0,0 +1,17 @@ +import { API_ROUTES } from 'data-services/constants' +import { EntityPicker } from 'nova-ui-kit' +import { FilterProps } from './types' + +export const SiteFilter = ({ onAdd, onClear, value }: FilterProps) => ( + { + if (value) { + onAdd(value) + } else { + onClear() + } + }} + value={value} + /> +) diff --git a/ui/src/pages/occurrences/occurrences.tsx b/ui/src/pages/occurrences/occurrences.tsx index a527c04ed..9fd1dd592 100644 --- a/ui/src/pages/occurrences/occurrences.tsx +++ b/ui/src/pages/occurrences/occurrences.tsx @@ -113,7 +113,14 @@ export const Occurrences = () => { @@ -121,6 +128,8 @@ export const Occurrences = () => { + + diff --git a/ui/src/pages/species/species-columns.tsx b/ui/src/pages/species/species-columns.tsx index b7f7a33ec..98d5218b2 100644 --- a/ui/src/pages/species/species-columns.tsx +++ b/ui/src/pages/species/species-columns.tsx @@ -19,7 +19,15 @@ import { STRING, translate } from 'utils/language' export const columns: (project: { projectId: string featureFlags?: { [key: string]: boolean } -}) => TableColumn[] = ({ projectId, featureFlags }) => [ + // Active taxa-list filters (station, verified, device, site, …) carried over + // when drilling into a taxon's occurrences so the occurrence list stays scoped + // to the same selection instead of showing every occurrence of the taxon. + carryFilters?: Record +}) => TableColumn[] = ({ + projectId, + featureFlags, + carryFilters = {}, +}) => [ { id: 'cover-image', name: translate(STRING.FIELD_LABEL_IMAGE), @@ -88,7 +96,7 @@ export const columns: (project: { @@ -106,7 +114,7 @@ export const columns: (project: { diff --git a/ui/src/pages/species/species.tsx b/ui/src/pages/species/species.tsx index 60b8e460f..1554ab6a9 100644 --- a/ui/src/pages/species/species.tsx +++ b/ui/src/pages/species/species.tsx @@ -32,6 +32,21 @@ import { useSort } from 'utils/useSort' import { columns } from './species-columns' import { SpeciesGallery } from './species-gallery' +// Taxa-list filters that also apply to the occurrence list. When the user drills +// into a taxon's occurrences via a bubble link, these are carried over so the +// occurrence list stays scoped to the same station / session / device / site / +// verification selection instead of resetting to every occurrence of the taxon. +const CARRY_OVER_FILTER_FIELDS = [ + 'event', + 'deployment', + 'deployment__device', + 'deployment__research_site', + 'verified', + 'taxa_list_id', + 'not_taxa_list_id', + 'apply_defaults', +] + export const Species = () => { const { projectId, id } = useParams() const { project } = useProjectDetails(projectId as string, true) @@ -58,6 +73,16 @@ export const Species = () => { const { selectedView, setSelectedView } = useSelectedView('table') const { taxaLists = [] } = useTaxaLists({ projectId: projectId as string }) const { tags = [] } = useTags({ projectId: projectId as string }) + const carryFilters = useMemo( + () => + filters.reduce>((acc, filter) => { + if (filter.value && CARRY_OVER_FILTER_FIELDS.includes(filter.field)) { + acc[filter.field] = filter.value + } + return acc + }, {}), + [filters] + ) const pageTitle = useMemo(() => { const taxaListFilter = filters.find( (filter) => filter.field === 'taxa_list_id' @@ -77,6 +102,8 @@ export const Species = () => { + + {taxaLists.length > 0 && ( <> @@ -135,6 +162,7 @@ export const Species = () => { columns={columns({ projectId: projectId as string, featureFlags: project?.featureFlags, + carryFilters, }).filter((column) => !!columnSettings[column.id])} error={error} isLoading={!id && isLoading} diff --git a/ui/src/utils/getAppRoute.ts b/ui/src/utils/getAppRoute.ts index ca4012f94..7c0854a69 100644 --- a/ui/src/utils/getAppRoute.ts +++ b/ui/src/utils/getAppRoute.ts @@ -3,10 +3,15 @@ type FilterType = | 'capture' | 'collection' | 'collections' + | 'date_end' + | 'date_start' | 'deployment' + | 'deployment__device' + | 'deployment__research_site' | 'detections__source_image' | 'event' | 'include_unobserved' + | 'not_taxa_list_id' | 'occurrence' | 'source_image_collection' | 'source_image_single' diff --git a/ui/src/utils/useFilters.ts b/ui/src/utils/useFilters.ts index b028a2592..32519cb81 100644 --- a/ui/src/utils/useFilters.ts +++ b/ui/src/utils/useFilters.ts @@ -76,6 +76,14 @@ export const AVAILABLE_FILTERS = (projectId: string): FilterConfig[] => [ }, }, }, + { + label: 'Device', + field: 'deployment__device', + }, + { + label: 'Site', + field: 'deployment__research_site', + }, { label: 'End date', field: 'date_end', From ff6be8abc898fee53119dc5116bdf97d158a6170 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Tue, 23 Jun 2026 22:24:03 -0700 Subject: [PATCH 2/7] fix: return 400 instead of 500 for a non-integer filter id get_occurrence_filters validated related-object ids with Model.objects.get(id=...) inside a try that only caught ObjectDoesNotExist. A non-integer value such as ?deployment__device=abc raised ValueError, which fell through as an unhandled 500. The occurrence list returns a 400 for the same input (via django-filter), so the two endpoints disagreed. Catch the non-integer case and raise a 400 so both endpoints agree. This covers the new deployment__device / deployment__research_site params and the pre-existing deployment / event / collection / occurrence ids, which shared the same lookup. Co-Authored-By: Claude --- ami/main/api/views.py | 5 +++++ ami/main/tests.py | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/ami/main/api/views.py b/ami/main/api/views.py index ab50f574a..050c9ed7f 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -1784,6 +1784,11 @@ def field(path: str) -> str: except exceptions.ObjectDoesNotExist as e: # Raise a 404 if any of the related objects don't exist raise NotFound(detail=str(e)) + except (ValueError, TypeError): + # A non-integer id (e.g. ?deployment__device=abc) is a client error. Return a + # 400 instead of letting the .get(id=...) lookup surface an unhandled 500, so + # this endpoint matches the 400 the occurrence list returns for the same input. + raise api_exceptions.ValidationError(detail="Filter ids must be integers.") return filters diff --git a/ami/main/tests.py b/ami/main/tests.py index 10bf73c4d..98f6f6dd3 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -6087,6 +6087,15 @@ def test_unknown_device_or_site_returns_404_on_taxa(self): res = self.client.get(f"/api/v2/taxa/?project_id={self.project.pk}&{param}=999999") self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND, param) + def test_non_integer_id_returns_400_not_500(self): + # A malformed id must be a client error on both endpoints, not an unhandled 500. + # The taxa view validates the id before the existence lookup; the occurrence view + # gets the same 400 from django-filter. Both must agree. + for endpoint in ("taxa", "occurrences"): + for param in ("deployment__device", "deployment__research_site"): + res = self.client.get(f"/api/v2/{endpoint}/?project_id={self.project.pk}&{param}=abc") + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST, f"{endpoint}?{param}=abc") + class TestDetectionNullMarker(TestCase): """ From 36c04a32dafd69b56c9ec4c5733e1a40769daaaf Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 24 Jun 2026 13:15:58 -0700 Subject: [PATCH 3/7] refactor: share the taxa-to-occurrence filter carry-over and apply it on the detail panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The taxa list and the taxon detail panel both link into the occurrence list from an occurrence count and a verified count. The list rows carried the active taxa-list filters (station, device, site, verification, ...) into that link, but the detail panel did not — it reset to every occurrence of the taxon. The two now behave the same. The carry-over field list and the logic that builds the filter object move into useFilters.ts as TAXA_OCCURRENCE_CARRY_OVER_FIELDS, buildCarryOverFilters, and the useCarryOverFilters hook, so there is a single source of truth instead of a constant living in one page component. The taxa table (via species.tsx) and the detail panel both consume the hook. No behavior change for the taxa table; the detail panel's two links now carry the same filters. Co-Authored-By: Claude --- .../pages/species-details/species-details.tsx | 10 ++++- ui/src/pages/species/species.tsx | 28 +------------ ui/src/utils/useFilters.ts | 40 +++++++++++++++++++ 3 files changed, 50 insertions(+), 28 deletions(-) diff --git a/ui/src/pages/species-details/species-details.tsx b/ui/src/pages/species-details/species-details.tsx index 6d4adc5f1..e1cd65d3e 100644 --- a/ui/src/pages/species-details/species-details.tsx +++ b/ui/src/pages/species-details/species-details.tsx @@ -20,6 +20,7 @@ import { APP_ROUTES } from 'utils/constants' import { getFormatedDateTimeString } from 'utils/date/getFormatedDateTimeString/getFormatedDateTimeString' import { getAppRoute } from 'utils/getAppRoute' import { STRING, translate } from 'utils/language' +import { useCarryOverFilters } from 'utils/useFilters' import { UserPermission } from 'utils/user/types' import styles from './species-details.module.scss' @@ -40,6 +41,7 @@ export const SpeciesDetails = ({ const { projectId } = useParams() const navigate = useNavigate() const { project } = useProjectDetails(projectId as string, true) + const carryFilters = useCarryOverFilters() const canUpdate = species.userPermissions.includes(UserPermission.Update) const hasChildren = species.rank !== 'SPECIES' @@ -154,7 +156,7 @@ export const SpeciesDetails = ({ to: APP_ROUTES.OCCURRENCES({ projectId: projectId as string, }), - filters: { taxon: species.id }, + filters: { ...carryFilters, taxon: species.id }, })} /> @@ -165,7 +167,11 @@ export const SpeciesDetails = ({ to: APP_ROUTES.OCCURRENCES({ projectId: projectId as string, }), - filters: { taxon: species.id, verified: 'true' }, + filters: { + ...carryFilters, + taxon: species.id, + verified: 'true', + }, })} /> diff --git a/ui/src/pages/species/species.tsx b/ui/src/pages/species/species.tsx index 1554ab6a9..594845671 100644 --- a/ui/src/pages/species/species.tsx +++ b/ui/src/pages/species/species.tsx @@ -25,28 +25,13 @@ import { APP_ROUTES } from 'utils/constants' import { getAppRoute } from 'utils/getAppRoute' import { STRING, translate } from 'utils/language' import { useColumnSettings } from 'utils/useColumnSettings' -import { useFilters } from 'utils/useFilters' +import { useCarryOverFilters, useFilters } from 'utils/useFilters' import { usePagination } from 'utils/usePagination' import { useSelectedView } from 'utils/useSelectedView' import { useSort } from 'utils/useSort' import { columns } from './species-columns' import { SpeciesGallery } from './species-gallery' -// Taxa-list filters that also apply to the occurrence list. When the user drills -// into a taxon's occurrences via a bubble link, these are carried over so the -// occurrence list stays scoped to the same station / session / device / site / -// verification selection instead of resetting to every occurrence of the taxon. -const CARRY_OVER_FILTER_FIELDS = [ - 'event', - 'deployment', - 'deployment__device', - 'deployment__research_site', - 'verified', - 'taxa_list_id', - 'not_taxa_list_id', - 'apply_defaults', -] - export const Species = () => { const { projectId, id } = useParams() const { project } = useProjectDetails(projectId as string, true) @@ -73,16 +58,7 @@ export const Species = () => { const { selectedView, setSelectedView } = useSelectedView('table') const { taxaLists = [] } = useTaxaLists({ projectId: projectId as string }) const { tags = [] } = useTags({ projectId: projectId as string }) - const carryFilters = useMemo( - () => - filters.reduce>((acc, filter) => { - if (filter.value && CARRY_OVER_FILTER_FIELDS.includes(filter.field)) { - acc[filter.field] = filter.value - } - return acc - }, {}), - [filters] - ) + const carryFilters = useCarryOverFilters() const pageTitle = useMemo(() => { const taxaListFilter = filters.find( (filter) => filter.field === 'taxa_list_id' diff --git a/ui/src/utils/useFilters.ts b/ui/src/utils/useFilters.ts index 32519cb81..6a956f91a 100644 --- a/ui/src/utils/useFilters.ts +++ b/ui/src/utils/useFilters.ts @@ -1,4 +1,5 @@ import { isBefore, isValid } from 'date-fns' +import { useMemo } from 'react' import { useParams, useSearchParams } from 'react-router-dom' import { APP_ROUTES } from './constants' import { STRING, translate } from './language' @@ -283,3 +284,42 @@ export const useFilters = (defaultFilters?: { [field: string]: string }) => { filters, } } + +// Taxa-list filters that also apply to the occurrence list. When the user opens a +// taxon's occurrences — from a count in the taxa table or from the taxon detail panel — +// these carry over so the occurrence list stays scoped to the same station, session, +// device, site, taxa-list, and verification selection instead of resetting to every +// occurrence of the taxon. The list is an explicit allow-list rather than the whole +// query string so taxa-only state (sort order, page number, "show unobserved taxa") +// does not leak into the occurrence URL. +export const TAXA_OCCURRENCE_CARRY_OVER_FIELDS = [ + 'event', + 'deployment', + 'deployment__device', + 'deployment__research_site', + 'verified', + 'taxa_list_id', + 'not_taxa_list_id', + 'apply_defaults', +] + +export const buildCarryOverFilters = ( + filters: { field: string; value?: string }[] +): Record => + filters.reduce>((acc, filter) => { + if ( + filter.value && + TAXA_OCCURRENCE_CARRY_OVER_FIELDS.includes(filter.field) + ) { + acc[filter.field] = filter.value + } + return acc + }, {}) + +// The active taxa-list filters that should carry into the occurrence list, as a plain +// object ready to spread into a `getAppRoute({ filters })` call. Used by both the taxa +// table rows and the taxon detail panel so the two stay in sync. +export const useCarryOverFilters = (): Record => { + const { filters } = useFilters() + return useMemo(() => buildCarryOverFilters(filters), [filters]) +} From 50bb5a227b9f0ceec4933a45aec13fcb0da2f2fc Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 24 Jun 2026 14:06:08 -0700 Subject: [PATCH 4/7] feat: match the taxa filter layout to the occurrence list and carry filters into the child-taxa link Reorder the taxa-list filter panel to mirror the occurrence list: the primary section now leads with Taxon, then Taxa in list / not in list, Verification status, Show unobserved taxa, and Default filters; Station, Device, Site, and the tag filters move into a "More filters" section that opens automatically when one of them is set. The two list views now read the same way. The taxon detail panel's "Child taxa" link also carries the active filters now, so stepping from a taxon to its children keeps the same station / device / site / verification scope, matching the occurrence and verified links next to it. Rename TAXA_OCCURRENCE_CARRY_OVER_FIELDS to CARRY_OVER_FILTER_FIELDS since the same set now feeds both the occurrence links and the child-taxa link. Co-Authored-By: Claude --- .../pages/species-details/species-details.tsx | 2 +- ui/src/pages/species/species.tsx | 63 ++++++++++++------- ui/src/utils/useFilters.ts | 27 ++++---- 3 files changed, 53 insertions(+), 39 deletions(-) diff --git a/ui/src/pages/species-details/species-details.tsx b/ui/src/pages/species-details/species-details.tsx index e1cd65d3e..abb8b918a 100644 --- a/ui/src/pages/species-details/species-details.tsx +++ b/ui/src/pages/species-details/species-details.tsx @@ -141,7 +141,7 @@ export const SpeciesDetails = ({ to: APP_ROUTES.TAXA({ projectId: projectId as string, }), - filters: { taxon: species.id }, + filters: { ...carryFilters, taxon: species.id }, })} /> diff --git a/ui/src/pages/species/species.tsx b/ui/src/pages/species/species.tsx index 594845671..d47a32c93 100644 --- a/ui/src/pages/species/species.tsx +++ b/ui/src/pages/species/species.tsx @@ -1,6 +1,7 @@ import { DefaultFiltersControl } from 'components/filtering/default-filter-control' import { FilterControl } from 'components/filtering/filter-control' import { FilterSection } from 'components/filtering/filter-section' +import { someActive } from 'components/filtering/utils' import { useProjectDetails } from 'data-services/hooks/projects/useProjectDetails' import { useSpecies } from 'data-services/hooks/species/useSpecies' import { useSpeciesDetails } from 'data-services/hooks/species/useSpeciesDetails' @@ -48,7 +49,7 @@ export const Species = () => { }) const { sort, setSort } = useSort({ field: 'name', order: 'asc' }) const { pagination, setPage } = usePagination() - const { filters } = useFilters() + const { activeFilters, filters } = useFilters() const { species, total, isLoading, isFetching, error } = useSpecies({ projectId, sort, @@ -75,28 +76,44 @@ export const Species = () => { return ( <>
- - - - - - - {taxaLists.length > 0 && ( - <> - - - - )} - - - {project?.featureFlags.tags ? ( - <> - - - - ) : null} - - +
+ + + + {taxaLists.length > 0 && ( + <> + + + + )} + + + + + + + + + {project?.featureFlags.tags ? ( + <> + + + + ) : null} + +
{ } } -// Taxa-list filters that also apply to the occurrence list. When the user opens a -// taxon's occurrences — from a count in the taxa table or from the taxon detail panel — -// these carry over so the occurrence list stays scoped to the same station, session, -// device, site, taxa-list, and verification selection instead of resetting to every -// occurrence of the taxon. The list is an explicit allow-list rather than the whole -// query string so taxa-only state (sort order, page number, "show unobserved taxa") -// does not leak into the occurrence URL. -export const TAXA_OCCURRENCE_CARRY_OVER_FIELDS = [ +// Taxa-list filters that also apply to the lists a taxon links into — the occurrence +// list (occurrence and verified counts) and the child-taxa list. When the user follows +// one of those links, from a count in the taxa table or from the taxon detail panel, +// these carry over so the destination stays scoped to the same station, session, device, +// site, taxa-list, and verification selection instead of resetting. The list is an +// explicit allow-list rather than the whole query string so source-only state (sort +// order, page number, "show unobserved taxa") does not leak into the destination URL. +export const CARRY_OVER_FILTER_FIELDS = [ 'event', 'deployment', 'deployment__device', @@ -307,18 +307,15 @@ export const buildCarryOverFilters = ( filters: { field: string; value?: string }[] ): Record => filters.reduce>((acc, filter) => { - if ( - filter.value && - TAXA_OCCURRENCE_CARRY_OVER_FIELDS.includes(filter.field) - ) { + if (filter.value && CARRY_OVER_FILTER_FIELDS.includes(filter.field)) { acc[filter.field] = filter.value } return acc }, {}) -// The active taxa-list filters that should carry into the occurrence list, as a plain -// object ready to spread into a `getAppRoute({ filters })` call. Used by both the taxa -// table rows and the taxon detail panel so the two stay in sync. +// The active taxa-list filters that should carry into a linked list, as a plain object +// ready to spread into a `getAppRoute({ filters })` call. Used by the taxa table rows +// and the taxon detail panel so every entry point stays in sync. export const useCarryOverFilters = (): Record => { const { filters } = useFilters() return useMemo(() => buildCarryOverFilters(filters), [filters]) From 33e4fd59d5df609b38243dbb273332cafaa95a81 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 24 Jun 2026 14:18:56 -0700 Subject: [PATCH 5/7] refactor: key filter carry-over by destination (FILTERS_TO_OCCURRENCES / FILTERS_TO_TAXA) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single carry-over field list with two destination-keyed sets in useFilters.ts: FILTERS_TO_OCCURRENCES and FILTERS_TO_TAXA, each listing the filter fields its destination list understands. Carry-over is now the intersection of the current view's active filters with the destination's set, so the source is implicit and any view that links to a destination reuses the same set — the behavior is consistent no matter where the link is. This also corrects two cases for the taxon detail panel: "show unobserved taxa" and the tag filters now carry along the child-taxa link (they are taxa-list filters) but still do not leak into the occurrence list (which does not support them). buildCarryOverFilters and the useCarryOverFilters hook take the destination field set as an argument. Only the taxa views consume them so far; the sets are ready for other views' links to reuse. Co-Authored-By: Claude --- .../pages/species-details/species-details.tsx | 15 +++-- ui/src/pages/species/species.tsx | 8 ++- ui/src/utils/useFilters.ts | 67 ++++++++++++++----- 3 files changed, 67 insertions(+), 23 deletions(-) diff --git a/ui/src/pages/species-details/species-details.tsx b/ui/src/pages/species-details/species-details.tsx index abb8b918a..394a3f582 100644 --- a/ui/src/pages/species-details/species-details.tsx +++ b/ui/src/pages/species-details/species-details.tsx @@ -20,7 +20,11 @@ import { APP_ROUTES } from 'utils/constants' import { getFormatedDateTimeString } from 'utils/date/getFormatedDateTimeString/getFormatedDateTimeString' import { getAppRoute } from 'utils/getAppRoute' import { STRING, translate } from 'utils/language' -import { useCarryOverFilters } from 'utils/useFilters' +import { + FILTERS_TO_OCCURRENCES, + FILTERS_TO_TAXA, + useCarryOverFilters, +} from 'utils/useFilters' import { UserPermission } from 'utils/user/types' import styles from './species-details.module.scss' @@ -41,7 +45,8 @@ export const SpeciesDetails = ({ const { projectId } = useParams() const navigate = useNavigate() const { project } = useProjectDetails(projectId as string, true) - const carryFilters = useCarryOverFilters() + const occurrenceFilters = useCarryOverFilters(FILTERS_TO_OCCURRENCES) + const taxaFilters = useCarryOverFilters(FILTERS_TO_TAXA) const canUpdate = species.userPermissions.includes(UserPermission.Update) const hasChildren = species.rank !== 'SPECIES' @@ -141,7 +146,7 @@ export const SpeciesDetails = ({ to: APP_ROUTES.TAXA({ projectId: projectId as string, }), - filters: { ...carryFilters, taxon: species.id }, + filters: { ...taxaFilters, taxon: species.id }, })} /> @@ -156,7 +161,7 @@ export const SpeciesDetails = ({ to: APP_ROUTES.OCCURRENCES({ projectId: projectId as string, }), - filters: { ...carryFilters, taxon: species.id }, + filters: { ...occurrenceFilters, taxon: species.id }, })} /> @@ -168,7 +173,7 @@ export const SpeciesDetails = ({ projectId: projectId as string, }), filters: { - ...carryFilters, + ...occurrenceFilters, taxon: species.id, verified: 'true', }, diff --git a/ui/src/pages/species/species.tsx b/ui/src/pages/species/species.tsx index d47a32c93..4f2977d54 100644 --- a/ui/src/pages/species/species.tsx +++ b/ui/src/pages/species/species.tsx @@ -26,7 +26,11 @@ import { APP_ROUTES } from 'utils/constants' import { getAppRoute } from 'utils/getAppRoute' import { STRING, translate } from 'utils/language' import { useColumnSettings } from 'utils/useColumnSettings' -import { useCarryOverFilters, useFilters } from 'utils/useFilters' +import { + FILTERS_TO_OCCURRENCES, + useCarryOverFilters, + useFilters, +} from 'utils/useFilters' import { usePagination } from 'utils/usePagination' import { useSelectedView } from 'utils/useSelectedView' import { useSort } from 'utils/useSort' @@ -59,7 +63,7 @@ export const Species = () => { const { selectedView, setSelectedView } = useSelectedView('table') const { taxaLists = [] } = useTaxaLists({ projectId: projectId as string }) const { tags = [] } = useTags({ projectId: projectId as string }) - const carryFilters = useCarryOverFilters() + const carryFilters = useCarryOverFilters(FILTERS_TO_OCCURRENCES) const pageTitle = useMemo(() => { const taxaListFilter = filters.find( (filter) => filter.field === 'taxa_list_id' diff --git a/ui/src/utils/useFilters.ts b/ui/src/utils/useFilters.ts index 3a732b265..61784f16a 100644 --- a/ui/src/utils/useFilters.ts +++ b/ui/src/utils/useFilters.ts @@ -285,38 +285,73 @@ export const useFilters = (defaultFilters?: { [field: string]: string }) => { } } -// Taxa-list filters that also apply to the lists a taxon links into — the occurrence -// list (occurrence and verified counts) and the child-taxa list. When the user follows -// one of those links, from a count in the taxa table or from the taxon detail panel, -// these carry over so the destination stays scoped to the same station, session, device, -// site, taxa-list, and verification selection instead of resetting. The list is an -// explicit allow-list rather than the whole query string so source-only state (sort -// order, page number, "show unobserved taxa") does not leak into the destination URL. -export const CARRY_OVER_FILTER_FIELDS = [ +// Filter carry-over between list views, keyed by DESTINATION. +// +// Each constant is the set of filter fields the destination list understands. The source +// is implicit: when a link is followed, whichever of these fields is currently active in +// the source view is carried into the destination URL, so the destination keeps the same +// scope (station, device, site, verification, ...) instead of resetting. Listing the +// destination's own fields — rather than copying the whole query string — keeps +// source-only state (sort order, page number, or a filter the destination does not +// support) out of the URL. Any view that links to a destination reuses its set, so the +// behavior stays consistent no matter where the link is. + +export const FILTERS_TO_OCCURRENCES = [ + 'detections__source_image', 'event', + 'taxon', + 'taxa_list_id', + 'not_taxa_list_id', + 'verified', + 'verified_by_me', + 'collection', + 'date_start', + 'date_end', 'deployment', 'deployment__device', 'deployment__research_site', - 'verified', + 'algorithm', + 'not_algorithm', + 'apply_defaults', +] + +export const FILTERS_TO_TAXA = [ + 'event', + 'taxon', 'taxa_list_id', 'not_taxa_list_id', + 'verified', + 'include_unobserved', + 'deployment', + 'deployment__device', + 'deployment__research_site', + 'tag_id', + 'not_tag_id', 'apply_defaults', ] +// Intersect the active filters with the fields the destination list understands, as a +// plain object ready to spread into a `getAppRoute({ filters })` call. export const buildCarryOverFilters = ( - filters: { field: string; value?: string }[] + filters: { field: string; value?: string }[], + fields: string[] ): Record => filters.reduce>((acc, filter) => { - if (filter.value && CARRY_OVER_FILTER_FIELDS.includes(filter.field)) { + if (filter.value && fields.includes(filter.field)) { acc[filter.field] = filter.value } return acc }, {}) -// The active taxa-list filters that should carry into a linked list, as a plain object -// ready to spread into a `getAppRoute({ filters })` call. Used by the taxa table rows -// and the taxon detail panel so every entry point stays in sync. -export const useCarryOverFilters = (): Record => { +// Hook form of buildCarryOverFilters: pass the destination's field set (e.g. +// FILTERS_TO_OCCURRENCES). Reads the active filters of the current view, so any link into +// that destination — from any source view — carries a consistent set. +export const useCarryOverFilters = ( + fields: string[] +): Record => { const { filters } = useFilters() - return useMemo(() => buildCarryOverFilters(filters), [filters]) + return useMemo( + () => buildCarryOverFilters(filters, fields), + [filters, fields] + ) } From 25d66a7e65a2578191903ba34d0c519339b7563e Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 24 Jun 2026 15:50:24 -0700 Subject: [PATCH 6/7] refactor: move the carry-over contracts into their destination modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each list's carry-over contract now lives next to that list's page: FILTERS_TO_OCCURRENCES in pages/occurrences/occurrence-filters.ts and FILTERS_TO_TAXA in pages/species/species-filters.ts, each documenting the two bounds it must satisfy (the backend honors the field, and the panel can display it so a carried filter is visible and clearable). The carry-over helper stays generic and destination-agnostic. Also: - Extract the pure buildCarryOverFilters into its own dependency-free module so it can be unit-tested without loading the filter registry; the useCarryOverFilters hook keeps it in useFilters.ts. - Add carryOverFilters.test.ts: the intersection behavior, no pagination/sort leak, and the "show unobserved taxa" taxa-only invariant. - Drop date_start / date_end / not_taxa_list_id from getAppRoute's FilterType union — they were type-only additions that are not used as explicit link params (carry-over spreads a string-keyed record, which does not need them in the union). Co-Authored-By: Claude --- .../pages/occurrences/occurrence-filters.ts | 33 ++++++++++ .../pages/species-details/species-details.tsx | 8 +-- ui/src/pages/species/species-filters.ts | 28 +++++++++ ui/src/pages/species/species.tsx | 7 +-- ui/src/utils/buildCarryOverFilters.ts | 23 +++++++ ui/src/utils/carryOverFilters.test.ts | 48 +++++++++++++++ ui/src/utils/getAppRoute.ts | 3 - ui/src/utils/useFilters.ts | 61 +------------------ 8 files changed, 139 insertions(+), 72 deletions(-) create mode 100644 ui/src/pages/occurrences/occurrence-filters.ts create mode 100644 ui/src/pages/species/species-filters.ts create mode 100644 ui/src/utils/buildCarryOverFilters.ts create mode 100644 ui/src/utils/carryOverFilters.test.ts diff --git a/ui/src/pages/occurrences/occurrence-filters.ts b/ui/src/pages/occurrences/occurrence-filters.ts new file mode 100644 index 000000000..a8fff9c0b --- /dev/null +++ b/ui/src/pages/occurrences/occurrence-filters.ts @@ -0,0 +1,33 @@ +// The occurrence list's filter carry-over contract: filter fields that another view may +// carry into the occurrence list (see useCarryOverFilters). A field belongs here only if +// both bounds hold: +// +// 1. The occurrence list backend honors it — keep this in sync by hand with the server +// filterset (OCCURRENCE_FILTERSET_FIELDS, the custom occurrence filter backends, and +// TaxonViewSet.get_occurrence_filters in ami/main/api/views.py). +// 2. The occurrence filter panel can display it — every field here has a control on the +// panel (a pickable control, or a readonly chip such as event/capture that appears +// once set), so a carried filter is always visible and clearable on arrival rather +// than silently shrinking the list. +// +// This is a curated subset, not "every field the panel renders" — a field can be on the +// panel yet deliberately left out of carry-over. The carryOverFilters test pins that every +// field here is a registered filter. +export const FILTERS_TO_OCCURRENCES = [ + 'detections__source_image', + 'event', + 'taxon', + 'taxa_list_id', + 'not_taxa_list_id', + 'verified', + 'verified_by_me', + 'collection', + 'date_start', + 'date_end', + 'deployment', + 'deployment__device', + 'deployment__research_site', + 'algorithm', + 'not_algorithm', + 'apply_defaults', +] diff --git a/ui/src/pages/species-details/species-details.tsx b/ui/src/pages/species-details/species-details.tsx index 394a3f582..81e8c5f53 100644 --- a/ui/src/pages/species-details/species-details.tsx +++ b/ui/src/pages/species-details/species-details.tsx @@ -20,11 +20,9 @@ import { APP_ROUTES } from 'utils/constants' import { getFormatedDateTimeString } from 'utils/date/getFormatedDateTimeString/getFormatedDateTimeString' import { getAppRoute } from 'utils/getAppRoute' import { STRING, translate } from 'utils/language' -import { - FILTERS_TO_OCCURRENCES, - FILTERS_TO_TAXA, - useCarryOverFilters, -} from 'utils/useFilters' +import { useCarryOverFilters } from 'utils/useFilters' +import { FILTERS_TO_OCCURRENCES } from 'pages/occurrences/occurrence-filters' +import { FILTERS_TO_TAXA } from 'pages/species/species-filters' import { UserPermission } from 'utils/user/types' import styles from './species-details.module.scss' diff --git a/ui/src/pages/species/species-filters.ts b/ui/src/pages/species/species-filters.ts new file mode 100644 index 000000000..157f76352 --- /dev/null +++ b/ui/src/pages/species/species-filters.ts @@ -0,0 +1,28 @@ +// The taxa list's filter carry-over contract: filter fields that another view may carry +// into the taxa list (see useCarryOverFilters). A field belongs here only if both bounds +// hold: +// +// 1. The taxa list backend honors it — keep this in sync by hand with the server +// filterset (TaxonViewSet.filterset_fields and get_occurrence_filters in +// ami/main/api/views.py). +// 2. The taxa filter panel can display it — every field here has a control on the panel +// (pickable, or a readonly chip such as event that appears once set), so a carried +// filter is always visible and clearable on arrival. +// +// Differs from FILTERS_TO_OCCURRENCES where the lists differ: the taxa list carries +// "show unobserved taxa" and the tag filters (its own filters) but not the occurrence-only +// ones. The carryOverFilters test pins that every field here is a registered filter. +export const FILTERS_TO_TAXA = [ + 'event', + 'taxon', + 'taxa_list_id', + 'not_taxa_list_id', + 'verified', + 'include_unobserved', + 'deployment', + 'deployment__device', + 'deployment__research_site', + 'tag_id', + 'not_tag_id', + 'apply_defaults', +] diff --git a/ui/src/pages/species/species.tsx b/ui/src/pages/species/species.tsx index 4f2977d54..0198ab42f 100644 --- a/ui/src/pages/species/species.tsx +++ b/ui/src/pages/species/species.tsx @@ -26,11 +26,8 @@ import { APP_ROUTES } from 'utils/constants' import { getAppRoute } from 'utils/getAppRoute' import { STRING, translate } from 'utils/language' import { useColumnSettings } from 'utils/useColumnSettings' -import { - FILTERS_TO_OCCURRENCES, - useCarryOverFilters, - useFilters, -} from 'utils/useFilters' +import { useCarryOverFilters, useFilters } from 'utils/useFilters' +import { FILTERS_TO_OCCURRENCES } from 'pages/occurrences/occurrence-filters' import { usePagination } from 'utils/usePagination' import { useSelectedView } from 'utils/useSelectedView' import { useSort } from 'utils/useSort' diff --git a/ui/src/utils/buildCarryOverFilters.ts b/ui/src/utils/buildCarryOverFilters.ts new file mode 100644 index 000000000..a40a8c1e7 --- /dev/null +++ b/ui/src/utils/buildCarryOverFilters.ts @@ -0,0 +1,23 @@ +// Carry filters from one list view into another. +// +// `fields` is the DESTINATION list's carry contract — the filter fields that destination +// honors and is willing to receive — defined as a constant next to that destination's page +// (e.g. FILTERS_TO_OCCURRENCES in pages/occurrences). The source is implicit: whichever of +// those fields is currently active in the source view is carried into the destination URL, +// so the destination keeps the same scope (station, device, site, verification, ...) +// instead of resetting. Passing the destination's own field list — rather than copying the +// whole query string — keeps source-only state (sort order, page number, or a filter the +// destination does not support) out of the URL. +// +// Pure and dependency-free so it can be unit-tested without loading the filter registry. +// The hook form, useCarryOverFilters, lives in useFilters.ts. +export const buildCarryOverFilters = ( + filters: { field: string; value?: string }[], + fields: string[] +): Record => + filters.reduce>((acc, filter) => { + if (filter.value && fields.includes(filter.field)) { + acc[filter.field] = filter.value + } + return acc + }, {}) diff --git a/ui/src/utils/carryOverFilters.test.ts b/ui/src/utils/carryOverFilters.test.ts new file mode 100644 index 000000000..4844816af --- /dev/null +++ b/ui/src/utils/carryOverFilters.test.ts @@ -0,0 +1,48 @@ +import { FILTERS_TO_OCCURRENCES } from 'pages/occurrences/occurrence-filters' +import { FILTERS_TO_TAXA } from 'pages/species/species-filters' +import { buildCarryOverFilters } from 'utils/buildCarryOverFilters' + +describe('buildCarryOverFilters', () => { + it('carries only active filters whose field is in the destination set', () => { + const filters = [ + { field: 'deployment', value: '5' }, + { field: 'verified', value: 'false' }, + // active, but not part of the occurrence carry contract -> dropped + { field: 'include_unobserved', value: 'true' }, + // part of the set, but inactive -> dropped + { field: 'taxon', value: undefined }, + ] + + expect(buildCarryOverFilters(filters, FILTERS_TO_OCCURRENCES)).toEqual({ + deployment: '5', + verified: 'false', + }) + }) + + it('returns an empty object when no active filter is in the set', () => { + const filters = [{ field: 'page', value: '3' }] + + expect(buildCarryOverFilters(filters, FILTERS_TO_OCCURRENCES)).toEqual({}) + }) +}) + +describe('carry-over contracts', () => { + // Source-only state must never carry into a destination URL, regardless of destination. + const SOURCE_ONLY = ['page', 'ordering'] + + it.each([ + ['FILTERS_TO_OCCURRENCES', FILTERS_TO_OCCURRENCES], + ['FILTERS_TO_TAXA', FILTERS_TO_TAXA], + ])( + '%s carries no pagination or sort state and has no duplicates', + (_n, fields) => { + expect(fields.filter((f) => SOURCE_ONLY.includes(f))).toEqual([]) + expect(new Set(fields).size).toBe(fields.length) + } + ) + + it('keeps "show unobserved taxa" a taxa-only filter, never carried to occurrences', () => { + expect(FILTERS_TO_TAXA).toContain('include_unobserved') + expect(FILTERS_TO_OCCURRENCES).not.toContain('include_unobserved') + }) +}) diff --git a/ui/src/utils/getAppRoute.ts b/ui/src/utils/getAppRoute.ts index 7c0854a69..03048396a 100644 --- a/ui/src/utils/getAppRoute.ts +++ b/ui/src/utils/getAppRoute.ts @@ -3,15 +3,12 @@ type FilterType = | 'capture' | 'collection' | 'collections' - | 'date_end' - | 'date_start' | 'deployment' | 'deployment__device' | 'deployment__research_site' | 'detections__source_image' | 'event' | 'include_unobserved' - | 'not_taxa_list_id' | 'occurrence' | 'source_image_collection' | 'source_image_single' diff --git a/ui/src/utils/useFilters.ts b/ui/src/utils/useFilters.ts index 61784f16a..8e9dd3336 100644 --- a/ui/src/utils/useFilters.ts +++ b/ui/src/utils/useFilters.ts @@ -1,6 +1,7 @@ import { isBefore, isValid } from 'date-fns' import { useMemo } from 'react' import { useParams, useSearchParams } from 'react-router-dom' +import { buildCarryOverFilters } from './buildCarryOverFilters' import { APP_ROUTES } from './constants' import { STRING, translate } from './language' import { SEARCH_PARAM_KEY_PAGE } from './usePagination' @@ -285,65 +286,7 @@ export const useFilters = (defaultFilters?: { [field: string]: string }) => { } } -// Filter carry-over between list views, keyed by DESTINATION. -// -// Each constant is the set of filter fields the destination list understands. The source -// is implicit: when a link is followed, whichever of these fields is currently active in -// the source view is carried into the destination URL, so the destination keeps the same -// scope (station, device, site, verification, ...) instead of resetting. Listing the -// destination's own fields — rather than copying the whole query string — keeps -// source-only state (sort order, page number, or a filter the destination does not -// support) out of the URL. Any view that links to a destination reuses its set, so the -// behavior stays consistent no matter where the link is. - -export const FILTERS_TO_OCCURRENCES = [ - 'detections__source_image', - 'event', - 'taxon', - 'taxa_list_id', - 'not_taxa_list_id', - 'verified', - 'verified_by_me', - 'collection', - 'date_start', - 'date_end', - 'deployment', - 'deployment__device', - 'deployment__research_site', - 'algorithm', - 'not_algorithm', - 'apply_defaults', -] - -export const FILTERS_TO_TAXA = [ - 'event', - 'taxon', - 'taxa_list_id', - 'not_taxa_list_id', - 'verified', - 'include_unobserved', - 'deployment', - 'deployment__device', - 'deployment__research_site', - 'tag_id', - 'not_tag_id', - 'apply_defaults', -] - -// Intersect the active filters with the fields the destination list understands, as a -// plain object ready to spread into a `getAppRoute({ filters })` call. -export const buildCarryOverFilters = ( - filters: { field: string; value?: string }[], - fields: string[] -): Record => - filters.reduce>((acc, filter) => { - if (filter.value && fields.includes(filter.field)) { - acc[filter.field] = filter.value - } - return acc - }, {}) - -// Hook form of buildCarryOverFilters: pass the destination's field set (e.g. +// Hook form of buildCarryOverFilters: pass the destination's carry contract (e.g. // FILTERS_TO_OCCURRENCES). Reads the active filters of the current view, so any link into // that destination — from any source view — carries a consistent set. export const useCarryOverFilters = ( From f631c66025f64d90a72d4c35a99a37839621d92e Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 24 Jun 2026 16:38:07 -0700 Subject: [PATCH 7/7] refactor: chain the filter-id exceptions and route the "More filters" label through i18n Two review fixes: - get_occurrence_filters now chains its re-raised exceptions (raise ... from e) for both the NotFound and the ValidationError paths, so the original ObjectDoesNotExist / ValueError traceback is preserved for debugging. Matches the existing pattern in ami/base/fields.py. - The "More filters" section title now goes through translate(STRING.MORE_FILTERS) instead of a hardcoded literal, shared by the taxa and occurrence filter panels. Co-Authored-By: Claude --- ami/main/api/views.py | 6 +++--- ui/src/pages/occurrences/occurrences.tsx | 2 +- ui/src/pages/species/species.tsx | 2 +- ui/src/utils/language.ts | 2 ++ 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/ami/main/api/views.py b/ami/main/api/views.py index 050c9ed7f..9be02fca3 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -1783,12 +1783,12 @@ def field(path: str) -> str: filters &= models.Q(**{field("detections__source_image__collections"): collection_id}) except exceptions.ObjectDoesNotExist as e: # Raise a 404 if any of the related objects don't exist - raise NotFound(detail=str(e)) - except (ValueError, TypeError): + raise NotFound(detail=str(e)) from e + except (ValueError, TypeError) as e: # A non-integer id (e.g. ?deployment__device=abc) is a client error. Return a # 400 instead of letting the .get(id=...) lookup surface an unhandled 500, so # this endpoint matches the 400 the occurrence list returns for the same input. - raise api_exceptions.ValidationError(detail="Filter ids must be integers.") + raise api_exceptions.ValidationError(detail="Filter ids must be integers.") from e return filters diff --git a/ui/src/pages/occurrences/occurrences.tsx b/ui/src/pages/occurrences/occurrences.tsx index 9fd1dd592..79bacc563 100644 --- a/ui/src/pages/occurrences/occurrences.tsx +++ b/ui/src/pages/occurrences/occurrences.tsx @@ -111,7 +111,7 @@ export const Occurrences = () => { {