From 4f817e9ce6929f12320e8190c76e100565439c4c Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 7 Oct 2026 10:48:57 -0700 Subject: [PATCH 1/5] feat(projects): search the project list by name and description Co-Authored-By: Claude Opus 5.5 --- ami/main/api/views.py | 1 + ami/main/tests.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/ami/main/api/views.py b/ami/main/api/views.py index 99709eb29..3d515b408 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -184,6 +184,7 @@ class ProjectViewSet(DefaultViewSet, ProjectMixin): serializer_class = ProjectSerializer pagination_class = ProjectPagination permission_classes = [ObjectPermission] + search_fields = ["name", "description"] ordering_fields = [ "name", "created_at", diff --git a/ami/main/tests.py b/ami/main/tests.py index 27785fe7f..6397e693b 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -2337,6 +2337,34 @@ def test_can_auto_assign_project_owner(self): self.assertEqual(self.user_1.id, project.owner.id) +class TestProjectListSearch(APITestCase): + """The project list `search` parameter matches name and description, and never reveals drafts.""" + + endpoint = "/api/v2/projects/" + + def setUp(self) -> None: + self.owner = User.objects.create_user(email="search-owner@insectai.org") + self.moth_project = Project.objects.create(name="Moths of Quebec", owner=self.owner) + self.beetle_project = Project.objects.create( + name="Field station", description="Night beetle survey", owner=self.owner + ) + self.draft_project = Project.objects.create(name="Draft moths", owner=self.owner, draft=True) + return super().setUp() + + def _search(self, term: str) -> set[int]: + response = self.client.get(self.endpoint, {"search": term}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + return {row["id"] for row in response.json()["results"]} + + def test_search_matches_name_and_description(self): + self.assertEqual(self._search("moths"), {self.moth_project.pk}) + self.assertEqual(self._search("beetle"), {self.beetle_project.pk}) + + def test_search_returns_draft_only_to_members(self): + self.client.force_authenticate(user=self.owner) + self.assertEqual(self._search("moths"), {self.moth_project.pk, self.draft_project.pk}) + + class TestProjectPermissions(APITestCase): def _create_project(self, owner, member): self.project = Project.objects.create(name="T Project", description="Test Description", owner=owner) From 81a11a23aa60a85427f742ff17e5c017a122fa0a Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 7 Oct 2026 10:49:02 -0700 Subject: [PATCH 2/5] feat(ui): add a search box to the projects page The search term lives in the URL like sort and view, and a new term returns to the first page in the same URL write. Co-Authored-By: Claude Opus 5.5 --- .../components/search-input/search-input.tsx | 38 +++++++++++++++++++ ui/src/pages/projects/projects.tsx | 18 +++++++-- ui/src/utils/language.ts | 2 + ui/src/utils/useSearch.ts | 23 +++++++++++ 4 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 ui/src/components/search-input/search-input.tsx create mode 100644 ui/src/utils/useSearch.ts diff --git a/ui/src/components/search-input/search-input.tsx b/ui/src/components/search-input/search-input.tsx new file mode 100644 index 000000000..2aa3c0786 --- /dev/null +++ b/ui/src/components/search-input/search-input.tsx @@ -0,0 +1,38 @@ +import { SearchIcon } from 'lucide-react' +import { useEffect, useState } from 'react' +import { useDebounce } from 'utils/useDebounce' + +const DEBOUNCE_DELAY = 300 + +export const SearchInput = ({ + label, + value, + onChange, +}: { + label: string + value: string + onChange: (value: string) => void +}) => { + const [searchString, setSearchString] = useState(value) + const debouncedSearchString = useDebounce(searchString, DEBOUNCE_DELAY) + + useEffect(() => { + if (debouncedSearchString.trim() !== value) { + onChange(debouncedSearchString.trim()) + } + }, [debouncedSearchString]) + + return ( +
+ + setSearchString(e.target.value)} + /> +
+ ) +} diff --git a/ui/src/pages/projects/projects.tsx b/ui/src/pages/projects/projects.tsx index 78873dcac..a9816856b 100644 --- a/ui/src/pages/projects/projects.tsx +++ b/ui/src/pages/projects/projects.tsx @@ -1,3 +1,4 @@ +import { SearchInput } from 'components/search-input/search-input' import { useProjects } from 'data-services/hooks/projects/useProjects' import { Button, @@ -11,6 +12,7 @@ import { NewProjectDialog } from 'pages/project-details/new-project-dialog' import { DOCS_LINKS } from 'utils/constants' import { STRING, translate } from 'utils/language' import { usePagination } from 'utils/usePagination' +import { useSearch } from 'utils/useSearch' import { UserPermission } from 'utils/user/types' import { useUser } from 'utils/user/userContext' import { useUserInfo } from 'utils/user/userInfoContext' @@ -51,10 +53,13 @@ export const Projects = () => { useSelectedView(user.loggedIn ? TABS.MY_PROJECTS : TABS.ALL_PROJECTS) const { sort, setSort } = useSort() const { pagination, setPage } = usePagination({ perPage: 40 }) - const filters = - user.loggedIn && selectedTab === TABS.MY_PROJECTS + const { search, setSearch } = useSearch() + const filters = [ + ...(user.loggedIn && selectedTab === TABS.MY_PROJECTS ? [{ field: 'user_id', value: userInfo?.id }] - : [] + : []), + ...(search ? [{ field: 'search', value: search }] : []), + ] const { projects, total, userPermissions, isLoading, isFetching, error } = useProjects({ pagination, filters, sort }) const canCreate = userPermissions?.includes(UserPermission.Create) @@ -88,6 +93,11 @@ export const Projects = () => { ) : null} + {canCreate ? : null} ({ @@ -98,7 +108,7 @@ export const Projects = () => { sort={sort} /> - {projects && projects.length === 0 && canCreate ? ( + {projects && projects.length === 0 && canCreate && !search ? (

Get started

diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index 38e20a5e4..51cebe129 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -39,6 +39,7 @@ export enum STRING { SAVE, SAVED, SEARCH_MAP, + SEARCH_PROJECTS, SEND_INSTRUCTIONS, SIGN_UP, STAR, @@ -425,6 +426,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.SAVE]: 'Save', [STRING.SAVED]: 'Saved', [STRING.SEARCH_MAP]: 'Search on the map', + [STRING.SEARCH_PROJECTS]: 'Search projects', [STRING.SEND_INSTRUCTIONS]: 'Send instructions', [STRING.SIGN_UP]: 'Sign up', [STRING.STAR]: 'Add to starred captures', diff --git a/ui/src/utils/useSearch.ts b/ui/src/utils/useSearch.ts new file mode 100644 index 000000000..55af31142 --- /dev/null +++ b/ui/src/utils/useSearch.ts @@ -0,0 +1,23 @@ +import { useSearchParams } from 'react-router-dom' +import { SEARCH_PARAM_KEY_PAGE } from './usePagination' + +const SEARCH_PARAM_KEY_SEARCH = 'search' + +export const useSearch = () => { + const [searchParams, setSearchParams] = useSearchParams() + const search = searchParams.get(SEARCH_PARAM_KEY_SEARCH) ?? '' + + const setSearch = (search: string) => { + // New results start from the first page. + searchParams.delete(SEARCH_PARAM_KEY_PAGE) + searchParams.delete(SEARCH_PARAM_KEY_SEARCH) + + if (search.length) { + searchParams.set(SEARCH_PARAM_KEY_SEARCH, search) + } + + setSearchParams(searchParams) + } + + return { search, setSearch } +} From 2fb6692e135d2268e7686118e7ad75e29dd00034 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Wed, 7 Oct 2026 12:53:28 -0700 Subject: [PATCH 3/5] fix(ui): keep the project search box in step with the URL Browser Back and Forward now update the text in the search box, and a pending keystroke can no longer restore the previous term. The text also sits on the same baseline as the neighbouring controls. Co-Authored-By: Claude Opus 5.5 --- .../components/search-input/search-input.tsx | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/ui/src/components/search-input/search-input.tsx b/ui/src/components/search-input/search-input.tsx index 2aa3c0786..d1a1ab950 100644 --- a/ui/src/components/search-input/search-input.tsx +++ b/ui/src/components/search-input/search-input.tsx @@ -1,6 +1,5 @@ import { SearchIcon } from 'lucide-react' -import { useEffect, useState } from 'react' -import { useDebounce } from 'utils/useDebounce' +import { useEffect, useRef, useState } from 'react' const DEBOUNCE_DELAY = 300 @@ -14,24 +13,38 @@ export const SearchInput = ({ onChange: (value: string) => void }) => { const [searchString, setSearchString] = useState(value) - const debouncedSearchString = useDebounce(searchString, DEBOUNCE_DELAY) + const timeoutRef = useRef>() + // Follow the value when it changes from outside the box, for example on + // browser Back, and drop any pending change so it cannot restore the old term. + // A value that only differs by surrounding spaces is the box's own update. useEffect(() => { - if (debouncedSearchString.trim() !== value) { - onChange(debouncedSearchString.trim()) - } - }, [debouncedSearchString]) + clearTimeout(timeoutRef.current) + setSearchString((current) => (current.trim() === value ? current : value)) + }, [value]) + + useEffect(() => () => clearTimeout(timeoutRef.current), []) + + const onInputChange = (newSearchString: string) => { + setSearchString(newSearchString) + clearTimeout(timeoutRef.current) + timeoutRef.current = setTimeout(() => { + if (newSearchString.trim() !== value) { + onChange(newSearchString.trim()) + } + }, DEBOUNCE_DELAY) + } return (

setSearchString(e.target.value)} + onChange={(e) => onInputChange(e.target.value)} />
) From 4a0d98013f2f1aff0305b48af2ddafac29ad69f7 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Thu, 8 Oct 2026 01:15:38 -0700 Subject: [PATCH 4/5] test: drop the search test that repeats the draft visibility tests Searching "moths" while signed out already shows the draft is left out, and members seeing drafts in the list is covered by the project draft tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EfiDZMVyc8qjWsHS5iKzrP --- ami/main/tests.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ami/main/tests.py b/ami/main/tests.py index 6397e693b..5025cdaf2 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -2360,10 +2360,6 @@ def test_search_matches_name_and_description(self): self.assertEqual(self._search("moths"), {self.moth_project.pk}) self.assertEqual(self._search("beetle"), {self.beetle_project.pk}) - def test_search_returns_draft_only_to_members(self): - self.client.force_authenticate(user=self.owner) - self.assertEqual(self._search("moths"), {self.moth_project.pk, self.draft_project.pk}) - class TestProjectPermissions(APITestCase): def _create_project(self, owner, member): From 13e87cc41e48a0732d4ee590c441988a7909e347 Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Thu, 8 Oct 2026 02:24:45 -0700 Subject: [PATCH 5/5] test: build the project search test data once per class Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EfiDZMVyc8qjWsHS5iKzrP --- ami/main/tests.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ami/main/tests.py b/ami/main/tests.py index 5025cdaf2..f24496256 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -2342,14 +2342,14 @@ class TestProjectListSearch(APITestCase): endpoint = "/api/v2/projects/" - def setUp(self) -> None: - self.owner = User.objects.create_user(email="search-owner@insectai.org") - self.moth_project = Project.objects.create(name="Moths of Quebec", owner=self.owner) - self.beetle_project = Project.objects.create( - name="Field station", description="Night beetle survey", owner=self.owner - ) - self.draft_project = Project.objects.create(name="Draft moths", owner=self.owner, draft=True) - return super().setUp() + @classmethod + def setUpTestData(cls) -> None: + cls.owner = User.objects.create_user(email="search-owner@insectai.org") + cls.moth_project = Project.objects.create(name="Moths of Quebec", owner=cls.owner) + cls.beetle_project = Project.objects.create( + name="Field station", description="Night beetle survey", owner=cls.owner + ) + cls.draft_project = Project.objects.create(name="Draft moths", owner=cls.owner, draft=True) def _search(self, term: str) -> set[int]: response = self.client.get(self.endpoint, {"search": term})