Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ami/main/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions ami/main/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
51 changes: 51 additions & 0 deletions ui/src/components/search-input/search-input.tsx
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const timeoutRef = useRef<ReturnType<typeof setTimeout>>()

// 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 (
<div className="flex items-center h-8 w-56 gap-2 px-4 rounded-full border border-input bg-background focus-within:ring-2 focus-within:ring-ring">
<SearchIcon className="w-4 h-4 shrink-0 text-muted-foreground" />
<input
aria-label={label}
className="w-full h-full pt-0.5 bg-transparent body-small outline-none placeholder:text-muted-foreground"
placeholder={label}
type="search"
value={searchString}
onChange={(e) => onInputChange(e.target.value)}
/>
</div>
)
}
18 changes: 14 additions & 4 deletions ui/src/pages/projects/projects.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SearchInput } from 'components/search-input/search-input'
import { useProjects } from 'data-services/hooks/projects/useProjects'
import {
Button,
Expand All @@ -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'
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -88,6 +93,11 @@ export const Projects = () => {
</Tabs.List>
</Tabs.Root>
) : null}
<SearchInput
label={translate(STRING.SEARCH_PROJECTS)}
value={search}
onChange={setSearch}
/>
{canCreate ? <NewProjectDialog /> : null}
<SortControl
columns={SORT_FIELDS.map((field) => ({
Expand All @@ -98,7 +108,7 @@ export const Projects = () => {
sort={sort}
/>
</PageHeader>
{projects && projects.length === 0 && canCreate ? (
{projects && projects.length === 0 && canCreate && !search ? (
<div className="flex flex-col items-center pt-32">
<h1 className="mb-8 heading-large">Get started</h1>
<p className="text-center body-large mb-16">
Expand Down
2 changes: 2 additions & 0 deletions ui/src/utils/language.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export enum STRING {
SAVE,
SAVED,
SEARCH_MAP,
SEARCH_PROJECTS,
SEND_INSTRUCTIONS,
SIGN_UP,
STAR,
Expand Down Expand Up @@ -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',
Expand Down
23 changes: 23 additions & 0 deletions ui/src/utils/useSearch.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading