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..f24496256 100644 --- a/ami/main/tests.py +++ b/ami/main/tests.py @@ -2337,6 +2337,30 @@ 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/" + + @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}) + 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}) + + class TestProjectPermissions(APITestCase): def _create_project(self, owner, member): self.project = Project.objects.create(name="T Project", description="Test Description", owner=owner) 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..d1a1ab950 --- /dev/null +++ b/ui/src/components/search-input/search-input.tsx @@ -0,0 +1,51 @@ +import { SearchIcon } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' + +const DEBOUNCE_DELAY = 300 + +export const SearchInput = ({ + label, + value, + onChange, +}: { + label: string + value: string + onChange: (value: string) => void +}) => { + const [searchString, setSearchString] = useState(value) + 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(() => { + 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 ( +
+ + onInputChange(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 } +}