Skip to content

Commit b3a0d0e

Browse files
Merge branch 'feat/add-taxa-tags' of https://github.com/RolnickLab/antenna into feat/add-taxa-tags
2 parents 04284ac + 0d3a9df commit b3a0d0e

15 files changed

Lines changed: 320 additions & 59 deletions

File tree

ui/src/components/filtering/filter-control.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ScoreFilter } from './filters/score-filter'
1010
import { SessionFilter } from './filters/session-filter'
1111
import { StationFilter } from './filters/station-filter'
1212
import { StatusFilter } from './filters/status-filter'
13+
import { TagFilter } from './filters/tag-filter'
1314
import { TaxaListFilter } from './filters/taxa-list-filter'
1415
import { TaxonFilter } from './filters/taxon-filter'
1516
import { TypeFilter } from './filters/type-filter'
@@ -34,6 +35,8 @@ const ComponentMap: {
3435
source_image_collection: CollectionFilter,
3536
source_image_single: ImageFilter,
3637
status: StatusFilter,
38+
tag_id: TagFilter,
39+
not_tag_id: TagFilter,
3740
taxon: TaxonFilter,
3841
taxa_list_id: TaxaListFilter,
3942
verified_by_me: VerifiedByFilter,
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Select } from 'nova-ui-kit'
2+
import { FilterProps } from './types'
3+
4+
export const TagFilter = ({ data = [], value, onAdd }: FilterProps) => {
5+
const tags = data as { id: number; name: string }[]
6+
7+
return (
8+
<Select.Root value={value ?? ''} onValueChange={onAdd}>
9+
<Select.Trigger>
10+
<Select.Value placeholder="Select a value" />
11+
</Select.Trigger>
12+
<Select.Content className="max-h-72">
13+
{tags.map((option) => (
14+
<Select.Item key={option.id} value={`${option.id}`}>
15+
{option.name}
16+
</Select.Item>
17+
))}
18+
</Select.Content>
19+
</Select.Root>
20+
)
21+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import classNames from 'classnames'
2+
3+
export const Tag = ({
4+
name,
5+
className,
6+
}: {
7+
name: string
8+
className?: string
9+
}) => (
10+
<div
11+
className={classNames(
12+
'h-6 inline-flex items-center px-3 rounded-full bg-primary text-primary-foreground body-small font-medium lowercase',
13+
className
14+
)}
15+
>
16+
{name}
17+
</div>
18+
)
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { useAssignTags } from 'data-services/hooks/taxa-tags/useAssignTags'
2+
import { useTags } from 'data-services/hooks/taxa-tags/useTags'
3+
import { Species, Tag } from 'data-services/models/species'
4+
import { Checkbox } from 'design-system/components/checkbox/checkbox'
5+
import { CheckIcon, Loader2Icon, PenIcon } from 'lucide-react'
6+
import { Button, Popover } from 'nova-ui-kit'
7+
import { useState } from 'react'
8+
import { useParams } from 'react-router-dom'
9+
import { STRING, translate } from 'utils/language'
10+
11+
const CLOSE_TIMEOUT = 1000
12+
13+
export const TagsForm = ({ species }: { species: Species }) => {
14+
const { projectId } = useParams()
15+
const [open, setOpen] = useState(false)
16+
const [formValues, setFormValues] = useState(species.tags)
17+
const { tags = [] } = useTags({ projectId: projectId as string })
18+
const { assignTags, isLoading, isSuccess } = useAssignTags(species.id, () =>
19+
setTimeout(() => setOpen(false), CLOSE_TIMEOUT)
20+
)
21+
22+
return (
23+
<Popover.Root
24+
open={open}
25+
onOpenChange={(open) => {
26+
setOpen(open)
27+
28+
// Reset form values on open change
29+
setFormValues(species.tags)
30+
}}
31+
>
32+
<Popover.Trigger asChild>
33+
<Button size="icon" variant="ghost" disabled={tags.length === 0}>
34+
<PenIcon className="w-4 h-4" />
35+
</Button>
36+
</Popover.Trigger>
37+
<Popover.Content align="end" className="w-64">
38+
<div>
39+
<span className="block body-overline font-semibold text-muted-foreground mb-4">
40+
{translate(STRING.ENTITY_EDIT, {
41+
type: translate(STRING.FIELD_LABEL_TAGS).toLowerCase(),
42+
})}
43+
</span>
44+
<div className="grid gap-2 mb-8">
45+
{tags.map((tag) => (
46+
<FormRow
47+
key={tag.id}
48+
checked={formValues.some((t) => t.id === tag.id)}
49+
onCheckedChange={(checked) => {
50+
if (checked) {
51+
setFormValues([...formValues, tag])
52+
} else {
53+
setFormValues(formValues.filter((t) => t.id !== tag.id))
54+
}
55+
}}
56+
tag={tag}
57+
/>
58+
))}
59+
</div>
60+
<div className="grid grid-cols-2 gap-2">
61+
<Button
62+
onClick={() => setOpen(false)}
63+
size="small"
64+
variant="outline"
65+
>
66+
<span>{translate(STRING.CANCEL)}</span>
67+
</Button>
68+
<Button
69+
onClick={() => assignTags(formValues)}
70+
size="small"
71+
variant="success"
72+
>
73+
<span>
74+
{isSuccess ? translate(STRING.SAVED) : translate(STRING.SAVE)}
75+
</span>
76+
{isSuccess ? (
77+
<CheckIcon className="w-4 h-4 ml-2" />
78+
) : isLoading ? (
79+
<Loader2Icon className="w-4 h-4 ml-2 animate-spin" />
80+
) : null}
81+
</Button>
82+
</div>
83+
</div>
84+
</Popover.Content>
85+
</Popover.Root>
86+
)
87+
}
88+
89+
const FormRow = ({
90+
checked,
91+
onCheckedChange,
92+
tag,
93+
}: {
94+
checked: boolean
95+
onCheckedChange: (checked: boolean) => void
96+
tag: Tag
97+
}) => (
98+
<div key={tag.id} className="flex items-center gap-2">
99+
<Checkbox
100+
id={`tag-${tag.id}`}
101+
checked={checked}
102+
onCheckedChange={onCheckedChange}
103+
/>
104+
<label
105+
htmlFor={`tag-${tag.id}`}
106+
className="flex items-center gap-2 body-small"
107+
>
108+
<div className="w-2 h-2 rounded-full bg-primary" />
109+
{tag.name.toLowerCase()}
110+
</label>
111+
</div>
112+
)

ui/src/data-services/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const API_ROUTES = {
2323
SESSIONS: 'events',
2424
SITES: 'deployments/sites',
2525
SPECIES: 'taxa',
26+
TAGS: 'tags',
2627
TAXA_LISTS: 'taxa/lists',
2728
STORAGE: 'storage',
2829
SUMMARY: 'status/summary',
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { useMutation, useQueryClient } from '@tanstack/react-query'
2+
import axios from 'axios'
3+
import { API_ROUTES, API_URL, SUCCESS_TIMEOUT } from 'data-services/constants'
4+
import { Tag } from 'data-services/models/species'
5+
import { getAuthHeader } from 'data-services/utils'
6+
import { useUser } from 'utils/user/userContext'
7+
8+
export const useAssignTags = (id: string, onSuccess?: () => void) => {
9+
const { user } = useUser()
10+
const queryClient = useQueryClient()
11+
12+
const { mutate, isLoading, error, isSuccess, reset } = useMutation({
13+
mutationFn: (tags: Tag[]) => {
14+
const data = new FormData()
15+
data.append('tag_ids', JSON.stringify(tags.map((tag) => tag.id)))
16+
17+
return axios.post(
18+
`${API_URL}/${API_ROUTES.SPECIES}/${id}/assign_tags/`,
19+
JSON.stringify({
20+
tag_ids: tags.map((tag) => tag.id),
21+
}),
22+
{
23+
headers: {
24+
...getAuthHeader(user),
25+
'Content-Type': 'application/json',
26+
},
27+
}
28+
)
29+
},
30+
onSuccess: () => {
31+
queryClient.invalidateQueries([API_ROUTES.SPECIES])
32+
queryClient.invalidateQueries([API_ROUTES.SPECIES, id])
33+
onSuccess?.()
34+
setTimeout(reset, SUCCESS_TIMEOUT)
35+
},
36+
})
37+
38+
return { assignTags: mutate, isLoading, error, isSuccess }
39+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { API_ROUTES } from 'data-services/constants'
2+
import { Tag } from 'data-services/models/species'
3+
import { getFetchUrl } from 'data-services/utils'
4+
import { UserPermission } from 'utils/user/types'
5+
import { useAuthorizedQuery } from '../auth/useAuthorizedQuery'
6+
7+
export const useTags = (params?: {
8+
projectId?: string
9+
}): {
10+
tags?: Tag[]
11+
userPermissions?: UserPermission[]
12+
isLoading: boolean
13+
isFetching: boolean
14+
error?: unknown
15+
} => {
16+
const fetchUrl = getFetchUrl({
17+
collection: API_ROUTES.TAGS,
18+
params,
19+
})
20+
21+
const { data, isLoading, isFetching, error } = useAuthorizedQuery<{
22+
results: Tag[]
23+
user_permissions?: UserPermission[]
24+
}>({
25+
queryKey: [API_ROUTES.TAGS, params],
26+
url: fetchUrl,
27+
})
28+
29+
return {
30+
tags: data?.results,
31+
userPermissions: data?.user_permissions,
32+
isLoading,
33+
isFetching,
34+
error,
35+
}
36+
}

ui/src/data-services/models/species-details.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export class SpeciesDetails extends Species {
1919
id: occurrence.id,
2020
image_url: occurrence.best_detection.url,
2121
caption: this.isUnknown
22-
? 'Center of cluster'
22+
? undefined
2323
: `${occurrence.determination.name} (${_.round(
2424
occurrence.determination_score,
2525
4

ui/src/data-services/models/species.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
1+
import { UserPermission } from 'utils/user/types'
12
import { Taxon } from './taxa'
23

34
export type ServerSpecies = any // TODO: Update this type
45

6+
export type Tag = { id: number; name: string }
7+
58
export class Species extends Taxon {
69
protected readonly _species: ServerSpecies
7-
private readonly _images: { src: string }[] = []
810

911
public constructor(species: ServerSpecies) {
1012
super(species)
1113
this._species = species
14+
}
1215

13-
if (species.occurrence_images?.length) {
14-
this._images = species.occurrence_images.map((image: any) => ({
15-
src: image,
16-
}))
17-
}
16+
get userPermissions(): UserPermission[] {
17+
return this._species.user_permissions
1818
}
1919

2020
get coverImage() {
@@ -25,17 +25,13 @@ export class Species extends Taxon {
2525
if (!this._species.cover_image_credit) {
2626
return {
2727
url: this._species.cover_image_url,
28-
caption: this.isUnknown
29-
? `${this.name} (most similar known taxon)`
30-
: this.name,
28+
caption: this.name,
3129
}
3230
}
3331

3432
return {
3533
url: this._species.cover_image_url,
36-
caption: this.isUnknown
37-
? `${this.name} (most similar known taxon), ${this._species.cover_image_credit}`
38-
: `${this.name}, ${this._species.cover_image_credit}`,
34+
caption: this.name,
3935
}
4036
}
4137

@@ -80,4 +76,10 @@ export class Species extends Taxon {
8076
get scoreLabel(): string {
8177
return this.score.toFixed(2)
8278
}
79+
80+
get tags(): Tag[] {
81+
const tags = this._species.tags ?? []
82+
83+
return tags.sort((t1: Tag, t2: Tag) => t1.id - t2.id)
84+
}
8385
}

ui/src/pages/species-details/species-details.module.scss

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,6 @@
1919
z-index: 1;
2020
}
2121

22-
.badge {
23-
padding: 6px 16px 4px;
24-
border-radius: 4px;
25-
background-color: $color-success-100;
26-
color: $color-success-700;
27-
text-align: center;
28-
@include label();
29-
}
30-
3122
.content {
3223
max-width: 100%;
3324
display: flex;

0 commit comments

Comments
 (0)