Skip to content

Commit 91aaf90

Browse files
annavikmihow
andauthored
Bulk actions (#460)
* Add more identification quick action options * Exclude applied rank from options and reverse list * Hide quick action section if no options * Add selectable feature to table component and use in occurrence view * Prepare bulk action bar UI * Make it possible to agree in bulk * Make it possible to apply id in bulk * Hide quick actions when in multi select mode * Only show common ranks in list of apply options * Add error handling to quick actions * Present recent identifications as apply options * Cleanup logic for identification options * Filter out items currently not visible from selected items * Disable outside close for identifications options popover --------- Co-authored-by: Michael Bunsen <notbot@gmail.com>
1 parent 660356e commit 91aaf90

26 files changed

+648
-121
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export interface IdentificationFieldValues {
2+
agreeWith?: {
3+
identificationId?: string
4+
predictionId?: string
5+
}
6+
occurrenceId: string
7+
taxonId: string
8+
comment?: string
9+
}

ui/src/data-services/hooks/identifications/useCreateIdentification.ts

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,7 @@ import axios from 'axios'
33
import { API_ROUTES, API_URL } from 'data-services/constants'
44
import { getAuthHeader } from 'data-services/utils'
55
import { useUser } from 'utils/user/userContext'
6-
7-
interface IdentificationFieldValues {
8-
agreeWith?: {
9-
identificationId?: string
10-
predictionId?: string
11-
}
12-
occurrenceId: string
13-
taxonId: string
14-
comment?: string
15-
}
6+
import { IdentificationFieldValues } from './types'
167

178
const convertToServerFieldValues = (
189
fieldValues: IdentificationFieldValues
@@ -49,6 +40,6 @@ export const useCreateIdentification = (onSuccess?: () => void) => {
4940
isLoading,
5041
isSuccess,
5142
reset,
52-
error,
43+
error: error ? 'The update was rejected, please retry.' : undefined,
5344
}
5445
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { SUCCESS_TIMEOUT } from 'data-services/constants'
2+
import { useEffect, useState } from 'react'
3+
import { IdentificationFieldValues } from './types'
4+
import { useCreateIdentification } from './useCreateIdentification'
5+
6+
export const useCreateIdentifications = (
7+
params: IdentificationFieldValues[]
8+
) => {
9+
const [results, setResults] = useState<PromiseSettledResult<any>[]>()
10+
const { createIdentification, isLoading, isSuccess, reset } =
11+
useCreateIdentification(() => {
12+
setTimeout(() => {
13+
reset()
14+
}, SUCCESS_TIMEOUT)
15+
})
16+
17+
const numRejected = results?.filter(
18+
(result) => result.status === 'rejected'
19+
).length
20+
21+
const error = numRejected
22+
? results.length > 1
23+
? `${numRejected}/${results.length} updates were rejected, please retry.`
24+
: 'The update was rejected, please retry.'
25+
: undefined
26+
27+
useEffect(() => {
28+
setResults(undefined)
29+
}, [params.length])
30+
31+
return {
32+
isLoading,
33+
isSuccess,
34+
error,
35+
createIdentifications: async () => {
36+
const promises = params
37+
.filter((_, index) => {
38+
if (error) {
39+
// Only retry rejected requests
40+
return results?.[index]?.status === 'rejected'
41+
}
42+
43+
return true
44+
})
45+
.map((variables) => createIdentification(variables))
46+
47+
setResults(undefined)
48+
const result = await Promise.allSettled(promises)
49+
setResults(result)
50+
},
51+
}
52+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { useMutation, useQueryClient } from '@tanstack/react-query'
2+
import axios from 'axios'
3+
import { API_ROUTES, API_URL } from 'data-services/constants'
4+
import { getAuthHeader } from 'data-services/utils'
5+
import { useUser } from 'utils/user/userContext'
6+
7+
export const useDeleteIdentification = (onSuccess?: () => void) => {
8+
const { user } = useUser()
9+
const queryClient = useQueryClient()
10+
11+
const { mutateAsync, isLoading, isSuccess, error } = useMutation({
12+
mutationFn: (id: string) =>
13+
axios.delete(`${API_URL}/${API_ROUTES.IDENTIFICATIONS}/${id}`, {
14+
headers: getAuthHeader(user),
15+
}),
16+
onSuccess: () => {
17+
queryClient.invalidateQueries([API_ROUTES.IDENTIFICATIONS])
18+
queryClient.invalidateQueries([API_ROUTES.OCCURRENCES])
19+
onSuccess?.()
20+
},
21+
})
22+
23+
return { deleteIdentification: mutateAsync, isLoading, isSuccess, error }
24+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
@import 'src/design-system/variables/colors.scss';
2+
@import 'src/design-system/variables/typography.scss';
3+
4+
.wrapper {
5+
bottom: 72px;
6+
position: fixed;
7+
border-radius: 32px;
8+
height: 64px;
9+
padding: 0 32px;
10+
display: flex;
11+
align-items: center;
12+
justify-content: center;
13+
gap: 16px;
14+
background-color: $color-generic-white;
15+
box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.1);
16+
}
17+
18+
.infoLabel {
19+
display: block;
20+
@include paragraph-small();
21+
font-weight: 600;
22+
color: $color-neutral-500;
23+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { ReactNode } from 'react'
2+
import { IconButton, IconButtonTheme } from '../icon-button/icon-button'
3+
import { IconType } from '../icon/icon'
4+
import styles from './bulk-action-bar.module.scss'
5+
6+
interface BulkActionBarProps {
7+
children: ReactNode
8+
selectedItems: string[]
9+
onClear: () => void
10+
}
11+
12+
export const BulkActionBar = ({
13+
children,
14+
selectedItems,
15+
onClear,
16+
}: BulkActionBarProps) => (
17+
<div className={styles.wrapper}>
18+
<span className={styles.infoLabel}>{selectedItems.length} selected</span>
19+
{children}
20+
<IconButton
21+
icon={IconType.Cross}
22+
theme={IconButtonTheme.Plain}
23+
onClick={onClear}
24+
/>
25+
</div>
26+
)

ui/src/design-system/components/checkbox/checkbox.module.scss

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
box-shadow: 0 0 0 2px $color-generic-black;
2323
}
2424

25-
&[data-state='checked'] {
25+
&[data-state='checked'],
26+
&[data-state='indeterminate'] {
2627
background-color: $color-neutral-600;
2728
border-color: $color-neutral-600;
2829
}

ui/src/design-system/components/checkbox/checkbox.tsx

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,37 +11,42 @@ export enum CheckboxTheme {
1111
}
1212

1313
interface CheckboxProps {
14-
id: string
14+
checked?: boolean | 'indeterminate'
15+
defaultChecked?: boolean
16+
id?: string
1517
label?: string
1618
theme?: CheckboxTheme
17-
checked?: boolean
1819
onCheckedChange?: (checked: boolean) => void
19-
defaultChecked?: boolean
2020
}
2121

2222
export const Checkbox = ({
23+
checked,
24+
defaultChecked,
2325
id,
2426
label,
2527
theme = CheckboxTheme.Default,
26-
checked,
2728
onCheckedChange,
28-
defaultChecked,
29-
}: CheckboxProps) => {
30-
return (
31-
<div className={styles.wrapper}>
32-
<_Checkbox.Root
33-
id={id}
34-
className={classNames(styles.checkboxRoot, {
35-
[styles.neutral]: theme === CheckboxTheme.Neutral,
36-
})}
37-
checked={checked}
38-
defaultChecked={defaultChecked}
39-
onCheckedChange={onCheckedChange}
40-
>
41-
<_Checkbox.Indicator className={styles.checkboxIndicator}>
29+
}: CheckboxProps) => (
30+
<div className={styles.wrapper}>
31+
<_Checkbox.Root
32+
checked={checked}
33+
className={classNames(styles.checkboxRoot, {
34+
[styles.neutral]: theme === CheckboxTheme.Neutral,
35+
})}
36+
defaultChecked={defaultChecked}
37+
id={id}
38+
onCheckedChange={onCheckedChange}
39+
>
40+
<_Checkbox.Indicator className={styles.checkboxIndicator}>
41+
{checked === true && (
4242
<Icon type={IconType.RadixCheck} theme={IconTheme.Light} />
43-
</_Checkbox.Indicator>
44-
</_Checkbox.Root>
43+
)}
44+
{checked === 'indeterminate' && (
45+
<Icon type={IconType.RadixMinus} theme={IconTheme.Light} />
46+
)}
47+
</_Checkbox.Indicator>
48+
</_Checkbox.Root>
49+
{label && (
4550
<label
4651
htmlFor={id}
4752
className={classNames(styles.label, {
@@ -52,6 +57,6 @@ export const Checkbox = ({
5257
>
5358
{label}
5459
</label>
55-
</div>
56-
)
57-
}
60+
)}
61+
</div>
62+
)
Lines changed: 5 additions & 0 deletions
Loading

ui/src/design-system/components/icon/icon.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import Error from './assets/radix/error.svg?react'
2323
import ExternalLink from './assets/radix/external-link.svg?react'
2424
import HeartFilled from './assets/radix/heart-filled.svg?react'
2525
import Heart from './assets/radix/heart.svg?react'
26+
import RadixMinus from './assets/radix/minus.svg?react'
2627
import Options from './assets/radix/options.svg?react'
2728
import Pencil from './assets/radix/pencil.svg?react'
2829
import Plus from './assets/radix/plus.svg?react'
@@ -68,6 +69,7 @@ export enum IconType {
6869
Plus = 'plus',
6970
RadixCheck = 'radix-check',
7071
RadixClock = 'radix-clock',
72+
RadixMinus = 'radix-minus',
7173
RadixQuestionMark = 'radix-question-mark',
7274
RadixSearch = 'radix-search',
7375
RadixTrash = 'radix-trash',
@@ -119,6 +121,7 @@ const COMPONENT_MAP: { [key in IconType]: FunctionComponent } = {
119121
[IconType.Plus]: Plus,
120122
[IconType.RadixCheck]: RadixCheck,
121123
[IconType.RadixClock]: RadixClock,
124+
[IconType.RadixMinus]: RadixMinus,
122125
[IconType.RadixQuestionMark]: RadixQuestionMark,
123126
[IconType.RadixSearch]: RadixSearch,
124127
[IconType.RadixTrash]: RadixTrash,

0 commit comments

Comments
 (0)