From 2c2b9d618af7b60702750415eb54022cd65f19d0 Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Tue, 3 Dec 2024 10:05:54 +0100 Subject: [PATCH 01/11] fix: make it possible to save user data (#642) --- .../user-info-form/user-info-form.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/ui/src/components/header/user-info-dialog/user-info-form/user-info-form.tsx b/ui/src/components/header/user-info-dialog/user-info-form/user-info-form.tsx index 388615432..d3792fffc 100644 --- a/ui/src/components/header/user-info-dialog/user-info-form/user-info-form.tsx +++ b/ui/src/components/header/user-info-dialog/user-info-form/user-info-form.tsx @@ -68,7 +68,7 @@ export const UserInfoForm = ({ userInfo }: { userInfo: UserInfo }) => { const errorMessage = useFormError({ error, setFieldError }) return ( - <> +
updateUserInfo(values))}> {errorMessage && ( { - updateUserInfo(values))} - style={{ display: 'contents' }} - > + <> { )} /> - + - - - - {config.collection && } - {config.station && } - {config.algorithm && } - {config.not_algorithm && } - - - - ) -} diff --git a/ui/src/components/filtering/filter-control.tsx b/ui/src/components/filtering/filter-control.tsx index dbf4ba9b0..ff1756183 100644 --- a/ui/src/components/filtering/filter-control.tsx +++ b/ui/src/components/filtering/filter-control.tsx @@ -14,6 +14,7 @@ import { TaxonFilter } from './filters/taxon-filter' import { TypeFilter } from './filters/type-filter' import { FilterProps } from './filters/types' import { VerificationStatusFilter } from './filters/verification-status-filter' +import { VerifiedByFilter } from './filters/verified-by-filter' const ComponentMap: { [key: string]: (props: FilterProps) => JSX.Element @@ -34,6 +35,7 @@ const ComponentMap: { taxon: TaxonFilter, type: TypeFilter, verified: VerificationStatusFilter, + verified_by_me: VerifiedByFilter, } interface FilterControlProps { diff --git a/ui/src/components/filtering/filtering.tsx b/ui/src/components/filtering/filter-section.tsx similarity index 70% rename from ui/src/components/filtering/filtering.tsx rename to ui/src/components/filtering/filter-section.tsx index 458fd7764..3f5ce2049 100644 --- a/ui/src/components/filtering/filtering.tsx +++ b/ui/src/components/filtering/filter-section.tsx @@ -3,18 +3,24 @@ import { ChevronsUpDown } from 'lucide-react' import { Box, Button, Collapsible } from 'nova-ui-kit' import { ReactNode } from 'react' -interface FilteringProps { +interface FilterSectionProps { children?: ReactNode + defaultOpen?: boolean + title?: string } -export const Filtering = ({ children }: FilteringProps) => ( +export const FilterSection = ({ + children, + defaultOpen, + title = 'Filters', +}: FilterSectionProps) => ( = BREAKPOINTS.MD} + defaultOpen={window.innerWidth >= BREAKPOINTS.MD ? defaultOpen : false} >
- Filters + {title} - )} + ) : null}
) } diff --git a/ui/src/components/filtering/filter-control.tsx b/ui/src/components/filtering/filter-control.tsx index ff1756183..06045a933 100644 --- a/ui/src/components/filtering/filter-control.tsx +++ b/ui/src/components/filtering/filter-control.tsx @@ -21,21 +21,21 @@ const ComponentMap: { } = { algorithm: AlgorithmFilter, classification_threshold: ScoreFilter, - date_start: DateFilter, - date_end: DateFilter, collection: CollectionFilter, + date_end: DateFilter, + date_start: DateFilter, deployment: StationFilter, detections__source_image: ImageFilter, event: SessionFilter, - pipeline: PipelineFilter, + job_type_key: TypeFilter, not_algorithm: NotAlgorithmFilter, + pipeline: PipelineFilter, source_image_collection: CollectionFilter, source_image_single: ImageFilter, status: StatusFilter, taxon: TaxonFilter, - type: TypeFilter, - verified: VerificationStatusFilter, verified_by_me: VerifiedByFilter, + verified: VerificationStatusFilter, } interface FilterControlProps { diff --git a/ui/src/data-services/models/capture.ts b/ui/src/data-services/models/capture.ts index 5557998ef..dc7986cad 100644 --- a/ui/src/data-services/models/capture.ts +++ b/ui/src/data-services/models/capture.ts @@ -101,6 +101,10 @@ export class Capture { return this._capture.detections_count ?? 0 } + get numJobs(): number { + return this._capture.jobs?.length ?? 0 + } + get numOccurrences(): number { return this._capture.occurrences_count ?? 0 } diff --git a/ui/src/pages/jobs/jobs.tsx b/ui/src/pages/jobs/jobs.tsx index d8714fad3..caa5d0ae3 100644 --- a/ui/src/pages/jobs/jobs.tsx +++ b/ui/src/pages/jobs/jobs.tsx @@ -54,13 +54,11 @@ export const Jobs = () => {
+ - {/* TODO: Uncomment when supported by backend */} - {/* */} + - {/* TODO: Uncomment when supported by backend */} - {/* */}
diff --git a/ui/src/pages/session-details/playback/capture-details/capture-details.module.scss b/ui/src/pages/session-details/playback/capture-details/capture-details.module.scss index 7089f262e..a0aca68eb 100644 --- a/ui/src/pages/session-details/playback/capture-details/capture-details.module.scss +++ b/ui/src/pages/session-details/playback/capture-details/capture-details.module.scss @@ -40,6 +40,15 @@ display: block; @include paragraph-x-small(); color: $color-generic-white; + + &.bubble { + height: auto; + padding: 6px 8px 4px; + border-radius: 4px; + border: 1px solid $color-neutral-600; + background-color: $color-neutral-600; + font-weight: 600; + } } @media only screen and (max-width: $small-screen-breakpoint) { diff --git a/ui/src/pages/session-details/playback/capture-details/capture-details.tsx b/ui/src/pages/session-details/playback/capture-details/capture-details.tsx index 86e139bd1..f7333938c 100644 --- a/ui/src/pages/session-details/playback/capture-details/capture-details.tsx +++ b/ui/src/pages/session-details/playback/capture-details/capture-details.tsx @@ -1,3 +1,4 @@ +import classNames from 'classnames' import { useStarCapture } from 'data-services/hooks/captures/useStarCapture' import { usePipelines } from 'data-services/hooks/pipelines/usePipelines' import { useProjectDetails } from 'data-services/hooks/projects/useProjectDetails' @@ -10,7 +11,9 @@ import { IconType } from 'design-system/components/icon/icon' import { Select, SelectTheme } from 'design-system/components/select/select' import { Tooltip } from 'design-system/components/tooltip/tooltip' import { useState } from 'react' -import { useParams } from 'react-router-dom' +import { Link, useParams } from 'react-router-dom' +import { APP_ROUTES } from 'utils/constants' +import { getAppRoute } from 'utils/getAppRoute' import { STRING, translate } from 'utils/language' import { useUser } from 'utils/user/userContext' import styles from './capture-details.module.scss' @@ -24,6 +27,7 @@ export const CaptureDetails = ({ captureId: string }) => { const { user } = useUser() + const { projectId } = useParams() if (!capture) { return null @@ -69,6 +73,25 @@ export const CaptureDetails = ({
)} +
+ + {translate(STRING.FIELD_LABEL_JOBS)} + + + + {capture.numJobs} + + +
{translate(STRING.FIELD_LABEL_DETECTIONS)} @@ -79,7 +102,20 @@ export const CaptureDetails = ({ {translate(STRING.FIELD_LABEL_OCCURRENCES)} - {capture.numOccurrences} + + + {capture.numOccurrences} + +
diff --git a/ui/src/utils/getAppRoute.ts b/ui/src/utils/getAppRoute.ts index 3158e104d..dfd877179 100644 --- a/ui/src/utils/getAppRoute.ts +++ b/ui/src/utils/getAppRoute.ts @@ -7,6 +7,7 @@ type FilterType = | 'taxon' | 'timestamp' | 'collection' + | 'source_image_single' export const getAppRoute = ({ to, diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index 2287858df..aead0a301 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -78,6 +78,7 @@ export enum STRING { FIELD_LABEL_ID, FIELD_LABEL_IMAGE, FIELD_LABEL_ICON, + FIELD_LABEL_JOBS, FIELD_LABEL_LAST_SYNCED, FIELD_LABEL_LATITUDE, FIELD_LABEL_LOCATION, @@ -298,6 +299,7 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.FIELD_LABEL_ID]: 'ID', [STRING.FIELD_LABEL_IMAGE]: 'Cover image', [STRING.FIELD_LABEL_ICON]: 'Icon', + [STRING.FIELD_LABEL_JOBS]: 'Jobs', [STRING.FIELD_LABEL_LAST_SYNCED]: 'Last synced with data source', [STRING.FIELD_LABEL_LATITUDE]: 'Latitude', [STRING.FIELD_LABEL_LOCATION]: 'Location', diff --git a/ui/src/utils/useFilters.ts b/ui/src/utils/useFilters.ts index f627ce849..d277401ce 100644 --- a/ui/src/utils/useFilters.ts +++ b/ui/src/utils/useFilters.ts @@ -15,7 +15,7 @@ export const AVAILABLE_FILTERS = [ }, { label: 'Collection', - field: 'source_image_collection', // This is for viewing Jobs by collection. @TODO can we update this key to "collection" to streamline? + field: 'source_image_collection', // This is for viewing Jobs by collection. @TODO: Can we update this key to "collection" to streamline? }, { label: 'Station', @@ -30,8 +30,8 @@ export const AVAILABLE_FILTERS = [ field: 'date_start', }, { - label: 'Image', - field: 'detections__source_image', // TODO: Can we update this key to "source_image" to streamline? + label: 'Source image', + field: 'detections__source_image', // This is for viewing Occurrences by source image. @TODO: Can we update this key to "source_image" to streamline? }, { label: 'Session', @@ -51,7 +51,7 @@ export const AVAILABLE_FILTERS = [ }, { label: 'Source image', - field: 'source_image_single', // TODO: Can we update this key to "source_image" to streamline? + field: 'source_image_single', // This is for viewing Jobs by source image. @TODO: Can we update this key to "source_image" to streamline? }, { label: 'Status', @@ -59,7 +59,7 @@ export const AVAILABLE_FILTERS = [ }, { label: 'Type', - field: 'type', + field: 'job_type_key', }, { label: 'Verification status', From 2d66e54e70e7edd3e354c0b7dd4550ac1da1725d Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Thu, 5 Dec 2024 01:28:56 -0800 Subject: [PATCH 06/11] fix: ensure minio and proxy are ready before the set permission scripts (#652) --- docker-compose.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 0a97873c7..4b8878fc4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -126,6 +126,8 @@ services: - "9000:9000" volumes: - ./compose/local/minio/nginx.conf:/etc/nginx/nginx.conf + depends_on: + - minio minio-init: image: minio/mc @@ -133,6 +135,7 @@ services: - ./.envs/.local/.django depends_on: - minio + - minio-proxy volumes: - ./compose/local/minio/init.sh:/etc/minio/init.sh entrypoint: /etc/minio/init.sh From 665f21658268ab5e0883a72830bce030dee8c07f Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Sat, 7 Dec 2024 00:16:25 +0100 Subject: [PATCH 07/11] fix: tweak logout logic to clear token on all types of errors (#654) --- .../data-services/hooks/auth/tests/useLogout.test.ts | 8 +++----- ui/src/data-services/hooks/auth/useLogout.ts | 10 ++-------- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/ui/src/data-services/hooks/auth/tests/useLogout.test.ts b/ui/src/data-services/hooks/auth/tests/useLogout.test.ts index 64d4c2c62..c1f84f818 100644 --- a/ui/src/data-services/hooks/auth/tests/useLogout.test.ts +++ b/ui/src/data-services/hooks/auth/tests/useLogout.test.ts @@ -48,7 +48,7 @@ describe('useLogout', () => { expect(removeQueriesSpy).toHaveBeenCalledTimes(1) }) - test('will keep the user logged in on error !== 403', async () => { + test('will logout the user on error !== 403', async () => { // Prep axios.post.mockImplementation(() => Promise.reject({ response: { status: 500 } }) @@ -62,9 +62,7 @@ describe('useLogout', () => { await waitFor(() => expect(result.current.error).not.toBeNull()) // Check - expect(localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)).toEqual( - 'example-token' - ) - expect(removeQueriesSpy).toHaveBeenCalledTimes(0) + expect(localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)).toBeNull() + expect(removeQueriesSpy).toHaveBeenCalledTimes(1) }) }) diff --git a/ui/src/data-services/hooks/auth/useLogout.ts b/ui/src/data-services/hooks/auth/useLogout.ts index ed6bd11d4..0a8402120 100644 --- a/ui/src/data-services/hooks/auth/useLogout.ts +++ b/ui/src/data-services/hooks/auth/useLogout.ts @@ -1,6 +1,6 @@ import { useMutation } from '@tanstack/react-query' import axios from 'axios' -import { API_ROUTES, API_URL, STATUS_CODES } from 'data-services/constants' +import { API_ROUTES, API_URL } from 'data-services/constants' import { getAuthHeader } from 'data-services/utils' import { useUser } from 'utils/user/userContext' @@ -12,13 +12,7 @@ export const useLogout = () => { headers: getAuthHeader(user), }), onSuccess: clearToken, - onError: (error: any) => { - if (error.response?.status === STATUS_CODES.FORBIDDEN) { - if (user.loggedIn) { - clearToken() - } - } - }, + onError: clearToken, }) return { logout: mutate, isLoading, isSuccess, error } From 394010367545c318416ab9add6de322329dfb18f Mon Sep 17 00:00:00 2001 From: Michael Bunsen Date: Fri, 6 Dec 2024 16:47:12 -0800 Subject: [PATCH 08/11] fix: use the same line-length setting everywhere (match pyproject.yml) (#653) --- .vscode/settings.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 0489b10dc..99b362223 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,8 +15,16 @@ "typescript.format.enable": true, "prettier.requireConfig": true, "[python]": { - "editor.defaultFormatter": "ms-python.black-formatter" + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.tabSize": 4, + "editor.rulers": [ + 119 + ] }, + "black-formatter.args": ["--line-length", "119"], + "flake8.args": [ + "--max-line-length", "119" + ], "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, From ae36fb224cfccc2183953c6922bf4fd77f82457e Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Tue, 17 Dec 2024 07:06:31 +0100 Subject: [PATCH 09/11] chore: hide detection counts (#656) --- ui/src/pages/collection-details/capture-columns.tsx | 11 ----------- .../pages/occurrence-details/occurrence-details.tsx | 4 ---- ui/src/pages/occurrences/occurrence-columns.tsx | 8 -------- .../playback/capture-details/capture-details.tsx | 6 ------ .../session-details/session-info/session-info.tsx | 4 ---- ui/src/pages/sessions/session-columns.tsx | 11 ----------- ui/src/pages/species-details/species-details.tsx | 9 ++++----- ui/src/utils/language.ts | 2 -- 8 files changed, 4 insertions(+), 51 deletions(-) diff --git a/ui/src/pages/collection-details/capture-columns.tsx b/ui/src/pages/collection-details/capture-columns.tsx index 8516f09cf..944522378 100644 --- a/ui/src/pages/collection-details/capture-columns.tsx +++ b/ui/src/pages/collection-details/capture-columns.tsx @@ -81,17 +81,6 @@ export const columns: (projectId: string) => TableColumn[] = ( ), }, - { - id: 'detections', - name: 'Detections', - sortField: 'detections_count', - styles: { - textAlign: TextAlign.Right, - }, - renderCell: (item: Capture) => ( - - ), - }, { id: 'occurrences', name: 'Occurrences', diff --git a/ui/src/pages/occurrence-details/occurrence-details.tsx b/ui/src/pages/occurrence-details/occurrence-details.tsx index acb2ec874..2ae1adc83 100644 --- a/ui/src/pages/occurrence-details/occurrence-details.tsx +++ b/ui/src/pages/occurrence-details/occurrence-details.tsx @@ -110,10 +110,6 @@ export const OccurrenceDetails = ({ label: translate(STRING.FIELD_LABEL_DURATION), value: occurrence.durationLabel, }, - { - label: translate(STRING.FIELD_LABEL_DETECTIONS), - value: occurrence.numDetections, - }, ] return ( diff --git a/ui/src/pages/occurrences/occurrence-columns.tsx b/ui/src/pages/occurrences/occurrence-columns.tsx index b24a50394..486539191 100644 --- a/ui/src/pages/occurrences/occurrence-columns.tsx +++ b/ui/src/pages/occurrences/occurrence-columns.tsx @@ -159,14 +159,6 @@ export const columns: ( /> ), }, - { - id: 'detections', - name: translate(STRING.FIELD_LABEL_DETECTIONS), - sortField: 'detections_count', - renderCell: (item: Occurrence) => ( - - ), - }, { id: 'created-at', name: translate(STRING.FIELD_LABEL_CREATED_AT), diff --git a/ui/src/pages/session-details/playback/capture-details/capture-details.tsx b/ui/src/pages/session-details/playback/capture-details/capture-details.tsx index f7333938c..8be77c6c6 100644 --- a/ui/src/pages/session-details/playback/capture-details/capture-details.tsx +++ b/ui/src/pages/session-details/playback/capture-details/capture-details.tsx @@ -92,12 +92,6 @@ export const CaptureDetails = ({
-
- - {translate(STRING.FIELD_LABEL_DETECTIONS)} - - {capture.numDetections} -
{translate(STRING.FIELD_LABEL_OCCURRENCES)} diff --git a/ui/src/pages/session-details/session-info/session-info.tsx b/ui/src/pages/session-details/session-info/session-info.tsx index a54944b70..642eb8480 100644 --- a/ui/src/pages/session-details/session-info/session-info.tsx +++ b/ui/src/pages/session-details/session-info/session-info.tsx @@ -34,10 +34,6 @@ export const SessionInfo = ({ session }: { session: Session }) => { label: translate(STRING.FIELD_LABEL_CAPTURES), value: session.numImages, }, - { - label: translate(STRING.FIELD_LABEL_DETECTIONS), - value: session.numDetections, - }, { label: translate(STRING.FIELD_LABEL_OCCURRENCES), value: session.numOccurrences, diff --git a/ui/src/pages/sessions/session-columns.tsx b/ui/src/pages/sessions/session-columns.tsx index 8343435c6..d764bf439 100644 --- a/ui/src/pages/sessions/session-columns.tsx +++ b/ui/src/pages/sessions/session-columns.tsx @@ -98,17 +98,6 @@ export const columns: (projectId: string) => TableColumn[] = ( }, renderCell: (item: Session) => , }, - { - id: 'detections', - name: translate(STRING.FIELD_LABEL_DETECTIONS), - sortField: 'detections_count', - styles: { - textAlign: TextAlign.Right, - }, - renderCell: (item: Session) => ( - - ), - }, { id: 'occurrences', name: translate(STRING.FIELD_LABEL_OCCURRENCES), diff --git a/ui/src/pages/species-details/species-details.tsx b/ui/src/pages/species-details/species-details.tsx index 295665251..dabb7fecd 100644 --- a/ui/src/pages/species-details/species-details.tsx +++ b/ui/src/pages/species-details/species-details.tsx @@ -44,13 +44,12 @@ export const SpeciesDetails = ({ species }: { species: Species }) => { ) const fields = [ - { - label: translate(STRING.FIELD_LABEL_DETECTIONS), - value: species.numDetections, - }, { label: translate(STRING.FIELD_LABEL_OCCURRENCES), - value: species.numOccurrences || 'View all', + value: + species.numOccurrences !== undefined + ? species.numOccurrences + : 'View all', to: getAppRoute({ to: APP_ROUTES.OCCURRENCES({ projectId: projectId as string }), filters: { taxon: species.id }, diff --git a/ui/src/utils/language.ts b/ui/src/utils/language.ts index aead0a301..7ac125a91 100644 --- a/ui/src/utils/language.ts +++ b/ui/src/utils/language.ts @@ -67,7 +67,6 @@ export enum STRING { FIELD_LABEL_DELAY, FIELD_LABEL_DEPLOYMENT, FIELD_LABEL_DESCRIPTION, - FIELD_LABEL_DETECTIONS, FIELD_LABEL_DEVICE, FIELD_LABEL_DURATION, FIELD_LABEL_EMAIL, @@ -289,7 +288,6 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = { [STRING.FIELD_LABEL_DEVICE]: 'Device type', [STRING.FIELD_LABEL_DEPLOYMENT]: 'Station', [STRING.FIELD_LABEL_DESCRIPTION]: 'Description', - [STRING.FIELD_LABEL_DETECTIONS]: 'Detections', [STRING.FIELD_LABEL_DURATION]: 'Duration', [STRING.FIELD_LABEL_EMAIL]: 'Email', [STRING.FIELD_LABEL_EMAIL_NEW]: 'Email new', From c5c9eb5e68c9ab1414ad3254e6101ccfe263356f Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Tue, 17 Dec 2024 07:44:33 +0100 Subject: [PATCH 10/11] Add client side error handling to date filters (#643) * feat: add error handling to date filters * feat: generic serializers for cleaning parameters in filtered views * feat: try the SingleParamSerializer * feat: activate missing taxa filter by collection * feat: return 400 response with validation messages instead of 500 * Unrelated type fix * Catch and show more detailed backend errors * Streamline error handling in rest of app * Add client side validation to check if start date is before end date --------- Co-authored-by: Michael Bunsen --- ami/base/serializers.py | 81 +++++++++++++++++++ ami/main/api/views.py | 43 ++++++---- ui/src/components/error-state/error-state.tsx | 31 +++++++ .../components/filtering/filter-control.tsx | 23 +++--- .../filtering/filters/date-filter.tsx | 30 +++++-- .../filtering/filters/type-filter.tsx | 4 +- ui/src/components/filtering/filters/types.ts | 3 +- ui/src/components/filtering/utils.ts | 2 +- ui/src/components/gallery/gallery.module.scss | 7 +- ui/src/components/gallery/gallery.tsx | 80 ++++++++++-------- ui/src/components/info-page/info-page.tsx | 41 +--------- ui/src/data-services/types.ts | 2 +- ui/src/data-services/utils.ts | 2 +- .../components/dialog/dialog.tsx | 4 +- .../components/table/table/table.module.scss | 13 +-- .../components/table/table/table.tsx | 39 ++++++--- .../collection-details/capture-gallery.tsx | 4 +- .../collection-details.module.scss | 11 --- .../collection-details/collection-details.tsx | 21 +++-- ui/src/pages/deployments/deployments.tsx | 12 +-- ui/src/pages/error/error.module.scss | 19 ----- ui/src/pages/error/error.tsx | 21 ----- ui/src/pages/jobs/jobs.tsx | 10 +-- .../pages/occurrences/occurrence-gallery.tsx | 8 +- .../pages/occurrences/occurrences.module.scss | 10 --- ui/src/pages/occurrences/occurrences.tsx | 30 +++---- .../overview/collections/collections.tsx | 12 +-- ui/src/pages/overview/entities/entities.tsx | 12 +-- ui/src/pages/overview/overview.tsx | 6 +- ui/src/pages/overview/pipelines/pipelines.tsx | 12 +-- ui/src/pages/overview/storage/storage.tsx | 13 +-- .../pipeline-details-dialog.tsx | 4 +- .../project-details/edit-project-dialog.tsx | 4 +- ui/src/pages/projects/project-gallery.tsx | 7 +- ui/src/pages/projects/projects.module.scss | 10 --- ui/src/pages/projects/projects.tsx | 11 ++- .../pages/session-details/session-details.tsx | 4 +- ui/src/pages/sessions/session-gallery.tsx | 4 +- ui/src/pages/sessions/sessions.module.scss | 11 --- ui/src/pages/sessions/sessions.tsx | 15 ++-- ui/src/pages/species/species-gallery.tsx | 8 +- ui/src/pages/species/species.module.scss | 11 --- ui/src/pages/species/species.tsx | 21 +++-- ui/src/utils/useFilters.ts | 63 +++++++++++++-- 44 files changed, 417 insertions(+), 362 deletions(-) create mode 100644 ui/src/components/error-state/error-state.tsx delete mode 100644 ui/src/pages/collection-details/collection-details.module.scss delete mode 100644 ui/src/pages/error/error.module.scss delete mode 100644 ui/src/pages/error/error.tsx delete mode 100644 ui/src/pages/sessions/sessions.module.scss delete mode 100644 ui/src/pages/species/species.module.scss diff --git a/ami/base/serializers.py b/ami/base/serializers.py index 4ace86dd5..69cc1a336 100644 --- a/ami/base/serializers.py +++ b/ami/base/serializers.py @@ -2,6 +2,7 @@ import urllib.parse from django.db import models +from rest_framework import exceptions as api_exceptions from rest_framework import serializers from rest_framework.request import Request from rest_framework.reverse import reverse @@ -91,3 +92,83 @@ def to_representation(self, instance): def create_for_model(cls, model: type[models.Model]) -> type["MinimalNestedModelSerializer"]: class_name = f"MinimalNestedModelSerializer_{model.__name__}" return type(class_name, (cls,), {"Meta": type("Meta", (), {"model": model, "fields": cls.Meta.fields})}) + + +T = typing.TypeVar("T") + + +class SingleParamSerializer(serializers.Serializer, typing.Generic[T]): + """ + A serializer for validating individual GET parameters in DRF views/filters. + + This class provides a reusable way to validate single parameters using DRF's + serializer fields, while maintaining type hints and clean error handling. + + Example: + >>> field = serializers.IntegerField(required=True, min_value=1) + >>> value = SingleParamSerializer[int].validate_param('page', field, request.query_params) + """ + + @classmethod + def clean( + cls, + param_name: str, + field: serializers.Field, + data: dict[str, typing.Any], + ) -> T: + """ + Validate a single parameter using the provided field configuration. + + Args: + param_name: The name of the parameter to validate + field: The DRF Field instance to use for validation + data: Dictionary containing the parameter value (typically request.query_params) + + Returns: + The validated and transformed parameter value + + Raises: + ValidationError: If the parameter value is invalid according to the field rules + """ + instance = cls(param_name, field, data=data) + if instance.is_valid(raise_exception=True): + return typing.cast(T, instance.validated_data.get(param_name)) + + # This shouldn't be reached due to raise_exception=True, but keeps type checker happy + raise api_exceptions.ValidationError(f"Invalid value for parameter: {param_name}") + + def __init__( + self, + param_name: str, + field: serializers.Field, + *args: typing.Any, + **kwargs: typing.Any, + ) -> None: + """ + Initialize the serializer with a single field for the given parameter. + + Args: + param_name: The name of the parameter to validate + field: The DRF Field instance to use for validation + *args: Additional positional arguments passed to parent + **kwargs: Additional keyword arguments passed to parent + """ + super().__init__(*args, **kwargs) + self.fields[param_name] = field + + +class FilterParamsSerializer(serializers.Serializer): + """ + Serializer for validating query parameters in DRF views. + Typically in filters for list views. + + A normal serializer with one helpful method to: + 1) run .is_valid() + 2) raise any validation exceptions + 3) then return the cleaned data. + """ + + def clean(self) -> dict[str, typing.Any]: + if self.is_valid(raise_exception=True): + return self.validated_data + raise api_exceptions.ValidationError("Invalid filter parameters") diff --git a/ami/main/api/views.py b/ami/main/api/views.py index 5b1f2cf10..e8e76e537 100644 --- a/ami/main/api/views.py +++ b/ami/main/api/views.py @@ -7,7 +7,7 @@ from django.db import models from django.db.models import Prefetch from django.db.models.query import QuerySet -from django.forms import BooleanField, CharField, DateField, IntegerField +from django.forms import BooleanField, CharField, IntegerField from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend from rest_framework import exceptions as api_exceptions @@ -23,6 +23,7 @@ from ami.base.filters import NullsLastOrderingFilter from ami.base.pagination import LimitOffsetPaginationWithPermissions from ami.base.permissions import IsActiveStaffOrReadOnly +from ami.base.serializers import FilterParamsSerializer, SingleParamSerializer from ami.utils.requests import get_active_classification_threshold from ami.utils.storages import ConnectionTestResult @@ -595,15 +596,14 @@ def populate(self, request, pk=None): def _get_source_image(self): """ - Allow parameter to be passed as a GET query param or in the request body. + Get source image from either GET query param or in the PUT/POST request body. """ key = "source_image" - try: - source_image_id = IntegerField(required=True, min_value=0).clean( - self.request.data.get(key) or self.request.query_params.get(key) - ) - except Exception as e: - raise api_exceptions.ValidationError from e + source_image_id = SingleParamSerializer[int].clean( + key, + field=serializers.IntegerField(required=True, min_value=0), + data=dict(self.request.data, **self.request.query_params), + ) try: return SourceImage.objects.get(id=source_image_id) @@ -831,18 +831,33 @@ def filter_queryset(self, request: Request, queryset, view): return queryset +class DateRangeFilterSerializer(FilterParamsSerializer): + date_start = serializers.DateField(required=False) + date_end = serializers.DateField(required=False) + + def validate(self, data): + """ + Additionally validate that the start date is before the end date. + """ + start_date = data.get("date_start") + end_date = data.get("date_end") + if start_date and end_date and start_date > end_date: + raise api_exceptions.ValidationError({"date_start": "Start date must be before end date"}) + return data + + class OccurrenceDateFilter(filters.BaseFilterBackend): """ Filter occurrences within a date range that their detections were observed. """ - query_param_start = "date_start" - query_param_end = "date_end" - def filter_queryset(self, request, queryset, view): # Validate and clean the query params. They should be in ISO format. - start_date = DateField(required=False).clean(request.query_params.get(self.query_param_start)) - end_date = DateField(required=False).clean(request.query_params.get(self.query_param_end)) + cleaned_data = DateRangeFilterSerializer(data=request.query_params).clean() + + # Access the validated dates + start_date = cleaned_data.get("date_start") + end_date = cleaned_data.get("date_end") if start_date: queryset = queryset.filter(detections__timestamp__date__gte=start_date) @@ -954,7 +969,7 @@ class TaxonViewSet(DefaultViewSet): queryset = Taxon.objects.all() serializer_class = TaxonSerializer - filter_backends = DefaultViewSetMixin.filter_backends + [CustomTaxonFilter] + filter_backends = DefaultViewSetMixin.filter_backends + [CustomTaxonFilter, TaxonCollectionFilter] filterset_fields = [ "name", "rank", diff --git a/ui/src/components/error-state/error-state.tsx b/ui/src/components/error-state/error-state.tsx new file mode 100644 index 000000000..f5b39b00b --- /dev/null +++ b/ui/src/components/error-state/error-state.tsx @@ -0,0 +1,31 @@ +import { AlertCircleIcon } from 'lucide-react' +import { useMemo } from 'react' + +interface ErrorStateProps { + error?: any +} + +export const ErrorState = ({ error }: ErrorStateProps) => { + const title = error?.message ?? 'Unknown error' + const data = error?.response?.data + + const description = useMemo(() => { + const entries = data ? Object.entries(data) : undefined + + if (entries?.length) { + const [key, value] = entries[0] + + return `${key}: ${value}` + } + }, [error]) + + return ( +
+ + {title} + {description ? ( + {description} + ) : null} +
+ ) +} diff --git a/ui/src/components/filtering/filter-control.tsx b/ui/src/components/filtering/filter-control.tsx index 06045a933..128550301 100644 --- a/ui/src/components/filtering/filter-control.tsx +++ b/ui/src/components/filtering/filter-control.tsx @@ -1,6 +1,6 @@ import { X } from 'lucide-react' import { Button } from 'nova-ui-kit' -import { AVAILABLE_FILTERS, useFilters } from 'utils/useFilters' +import { useFilters } from 'utils/useFilters' import { AlgorithmFilter, NotAlgorithmFilter } from './filters/algorithm-filter' import { CollectionFilter } from './filters/collection-filter' import { DateFilter } from './filters/date-filter' @@ -50,32 +50,30 @@ export const FilterControl = ({ readonly, }: FilterControlProps) => { const { filters, addFilter, clearFilter } = useFilters() - const label = AVAILABLE_FILTERS.find( - (filter) => filter.field === field - )?.label - const value = filters.find((filter) => filter.field === field)?.value + const filter = filters.find((filter) => filter.field === field) const FilterComponent = ComponentMap[field] - if (!label || !FilterComponent) { + if (!filter || !FilterComponent) { return null } - if (readonly && !value) { + if (readonly && !filter?.value) { return null } return (
addFilter(field, value)} onClear={() => clearFilter(field)} + value={filter.value} /> - {clearable && value && ( + {clearable && filter.value && (
+ {filter.error ? ( + + {filter.error} + + ) : null}
) } diff --git a/ui/src/components/filtering/filters/date-filter.tsx b/ui/src/components/filtering/filters/date-filter.tsx index 00243e0ad..c7993566d 100644 --- a/ui/src/components/filtering/filters/date-filter.tsx +++ b/ui/src/components/filtering/filters/date-filter.tsx @@ -1,15 +1,29 @@ import { format } from 'date-fns' -import { Calendar as CalendarIcon } from 'lucide-react' +import { AlertCircleIcon, Calendar as CalendarIcon } from 'lucide-react' import { Button, Calendar, Popover } from 'nova-ui-kit' import { useState } from 'react' import { FilterProps } from './types' -const dateToLabel = (date: Date) => format(date, 'yyyy-MM-dd') +const dateToLabel = (date: Date) => { + try { + return format(date, 'yyyy-MM-dd') + } catch { + return 'Invalid date' + } +} -export const DateFilter = ({ value, onAdd, onClear }: FilterProps) => { +export const DateFilter = ({ isValid, onAdd, onClear, value }: FilterProps) => { const [open, setOpen] = useState(false) const selected = value ? new Date(value) : undefined + const triggerLabel = (() => { + if (!value) { + return 'Select a date' + } + + return value + })() + return ( @@ -17,11 +31,15 @@ export const DateFilter = ({ value, onAdd, onClear }: FilterProps) => { variant="outline" role="combobox" aria-expanded={open} - className="w-72 justify-between text-muted-foreground font-normal" + className="w-full justify-between text-muted-foreground font-normal" > <> - {selected ? dateToLabel(selected) : 'Select a date'} - + {triggerLabel} + {selected && !isValid ? ( + + ) : ( + + )} diff --git a/ui/src/components/filtering/filters/type-filter.tsx b/ui/src/components/filtering/filters/type-filter.tsx index 1a50eb967..c489d2ed1 100644 --- a/ui/src/components/filtering/filters/type-filter.tsx +++ b/ui/src/components/filtering/filters/type-filter.tsx @@ -3,10 +3,10 @@ import { Select } from 'nova-ui-kit' import { FilterProps } from './types' const OPTIONS = SERVER_JOB_TYPES.map((key) => { - const typrInfo = Job.getJobTypeInfo(key) + const typeInfo = Job.getJobTypeInfo(key) return { - ...typrInfo, + ...typeInfo, } }) diff --git a/ui/src/components/filtering/filters/types.ts b/ui/src/components/filtering/filters/types.ts index a2e2b00df..51c0ad8cb 100644 --- a/ui/src/components/filtering/filters/types.ts +++ b/ui/src/components/filtering/filters/types.ts @@ -1,5 +1,6 @@ export interface FilterProps { - value: string | undefined + isValid?: boolean onAdd: (value: string) => void onClear: () => void + value: string | undefined } diff --git a/ui/src/components/filtering/utils.ts b/ui/src/components/filtering/utils.ts index 675ba0aaf..82ce2f9a3 100644 --- a/ui/src/components/filtering/utils.ts +++ b/ui/src/components/filtering/utils.ts @@ -18,5 +18,5 @@ export const booleanToString = (value?: boolean) => // Help function to decide if a filter section should be open or not on page load export const someActive = ( fields: string[], - activeFilters: { field: string; value: string }[] + activeFilters: { field: string }[] ) => activeFilters.some(({ field }) => fields.includes(field)) diff --git a/ui/src/components/gallery/gallery.module.scss b/ui/src/components/gallery/gallery.module.scss index 42865049b..f1aa2dec6 100644 --- a/ui/src/components/gallery/gallery.module.scss +++ b/ui/src/components/gallery/gallery.module.scss @@ -9,6 +9,7 @@ gap: 16px; width: 100%; min-height: 320px; + padding-top: 24px; &.loading { margin: 0; @@ -20,11 +21,8 @@ } .loadingWrapper { - position: absolute; width: 100%; - height: 100%; - top: 0; - right: 0; + min-height: 320px; display: flex; align-items: center; justify-content: center; @@ -45,5 +43,6 @@ @media only screen and (max-width: $small-screen-breakpoint) { .gallery { grid-template-columns: 1fr !important; + padding-top: 16px; } } diff --git a/ui/src/components/gallery/gallery.tsx b/ui/src/components/gallery/gallery.tsx index d4579ac50..ecf931965 100644 --- a/ui/src/components/gallery/gallery.tsx +++ b/ui/src/components/gallery/gallery.tsx @@ -1,5 +1,6 @@ import classNames from 'classnames' import { EmptyState } from 'components/empty-state/empty-state' +import { ErrorState } from 'components/error-state/error-state' import { Card, CardSize } from 'design-system/components/card/card' import { LoadingSpinner } from 'design-system/components/loading-spinner/loading-spinner' import { CSSProperties } from 'react' @@ -19,51 +20,66 @@ interface GalleryItem { export const Gallery = ({ cardSize, + error, isLoading, items, renderItem, style, }: { cardSize?: CardSize + error?: any isLoading: boolean items: GalleryItem[] renderItem?: (item: GalleryItem) => JSX.Element style?: CSSProperties -}) => ( -
- {items?.map( - (item) => - renderItem?.(item) ?? - (item.to ? ( - +}) => { + if (isLoading) { + return ( +
+ +
+ ) + } + + if (error) { + return + } + + if (items.length === 0) { + return + } + + return ( +
+ {items?.map( + (item) => + renderItem?.(item) ?? + (item.to ? ( + + + + ) : ( - - ) : ( - - )) - )} - {isLoading && ( -
- -
- )} - {!isLoading && items.length === 0 && } -
-) + )) + )} + + {!isLoading && items.length === 0 && } +
+ ) +} diff --git a/ui/src/components/info-page/info-page.tsx b/ui/src/components/info-page/info-page.tsx index 0cc67e658..8dfb834f0 100644 --- a/ui/src/components/info-page/info-page.tsx +++ b/ui/src/components/info-page/info-page.tsx @@ -1,7 +1,3 @@ -import { usePageDetails } from 'data-services/hooks/pages/usePageDetails' -import { LoadingSpinner } from 'design-system/components/loading-spinner/loading-spinner' -import { Error } from 'pages/error/error' -import styles from './info-page.module.scss' import { TERMS_OF_SERVICE_SLUG } from './terms-of-service-page/constants' import { TermsOfServicePage } from './terms-of-service-page/terms-of-service-page' @@ -10,40 +6,5 @@ export const InfoPage = ({ slug }: { slug: string }) => { return } - return -} - -const InfoPageContent = ({ slug }: { slug: string }) => { - const { page, isLoading, error } = usePageDetails(slug) - - if (isLoading) { - return ( -
-
- -
-
- ) - } - - if (error) { - return ( -
- -
- ) - } - - return ( -
-
- {page ? ( -
- ) : null} -
-
- ) + return null } diff --git a/ui/src/data-services/types.ts b/ui/src/data-services/types.ts index fbb89c5e9..8822e3994 100644 --- a/ui/src/data-services/types.ts +++ b/ui/src/data-services/types.ts @@ -2,7 +2,7 @@ export interface FetchParams { projectId?: string pagination?: { page: number; perPage: number } sort?: { field: string; order: 'asc' | 'desc' } - filters?: { field: string; value: string }[] + filters?: { field: string; value?: string; isValid?: boolean }[] } export interface APIValidationError { diff --git a/ui/src/data-services/utils.ts b/ui/src/data-services/utils.ts index 1c501a791..23eaf1e27 100644 --- a/ui/src/data-services/utils.ts +++ b/ui/src/data-services/utils.ts @@ -27,7 +27,7 @@ export const getFetchUrl = ({ } if (params?.filters?.length) { params.filters.forEach((filter) => { - if (filter.value?.length) { + if (filter.value?.length && filter.isValid) { queryParams[filter.field] = filter.value } }) diff --git a/ui/src/design-system/components/dialog/dialog.tsx b/ui/src/design-system/components/dialog/dialog.tsx index 67a1decad..2897e7a88 100644 --- a/ui/src/design-system/components/dialog/dialog.tsx +++ b/ui/src/design-system/components/dialog/dialog.tsx @@ -1,6 +1,6 @@ import * as Dialog from '@radix-ui/react-dialog' import classNames from 'classnames' -import { Error } from 'pages/error/error' +import { ErrorState } from 'components/error-state/error-state' import { ReactNode } from 'react' import { Icon, IconType } from '../icon/icon' import { LoadingSpinner } from '../loading-spinner/loading-spinner' @@ -58,7 +58,7 @@ const Content = ({
{error ? (
- +
) : ( children diff --git a/ui/src/design-system/components/table/table/table.module.scss b/ui/src/design-system/components/table/table/table.module.scss index eea1d2b89..47180e8da 100644 --- a/ui/src/design-system/components/table/table/table.module.scss +++ b/ui/src/design-system/components/table/table/table.module.scss @@ -25,11 +25,8 @@ } .loadingWrapper { - position: absolute; width: 100%; - height: 100%; - top: 0; - right: 0; + min-height: 320px; display: flex; align-items: center; justify-content: center; @@ -68,10 +65,6 @@ border-collapse: collapse; } - &:has(tbody.loading) { - overflow: hidden; - } - tbody { position: relative; @@ -82,10 +75,6 @@ tr:not(:last-child) { border-bottom: 1px solid $color-neutral-100; } - - &.loading { - opacity: 0.5; - } } td { diff --git a/ui/src/design-system/components/table/table/table.tsx b/ui/src/design-system/components/table/table/table.tsx index 68dd17652..b243fe408 100644 --- a/ui/src/design-system/components/table/table/table.tsx +++ b/ui/src/design-system/components/table/table/table.tsx @@ -1,5 +1,6 @@ import classNames from 'classnames' import { EmptyState } from 'components/empty-state/empty-state' +import { ErrorState } from 'components/error-state/error-state' import { Checkbox } from 'design-system/components/checkbox/checkbox' import { LoadingSpinner } from 'design-system/components/loading-spinner/loading-spinner' import { Tooltip } from 'design-system/components/tooltip/tooltip' @@ -20,27 +21,29 @@ export enum TableBackgroundTheme { interface TableProps { backgroundTheme?: TableBackgroundTheme columns: TableColumn[] - items?: T[] + error?: any isLoading?: boolean + items?: T[] + onSelectedItemsChange?: (selectedItems: string[]) => void + onSortSettingsChange?: (sortSettings?: TableSortSettings) => void selectable?: boolean selectedItems?: string[] sortable?: boolean sortSettings?: TableSortSettings - onSelectedItemsChange?: (selectedItems: string[]) => void - onSortSettingsChange?: (sortSettings?: TableSortSettings) => void } export const Table = ({ backgroundTheme = TableBackgroundTheme.Neutral, columns, - items = [], + error, isLoading, + items = [], + onSelectedItemsChange, + onSortSettingsChange, selectable, selectedItems = [], sortable, sortSettings, - onSelectedItemsChange, - onSortSettingsChange, }: TableProps) => { const tableContainerRef = useRef(null) const showScrollFader = useScrollFader(tableContainerRef, [ @@ -49,6 +52,22 @@ export const Table = ({ tableContainerRef.current, ]) + if (isLoading) { + return ( +
+ +
+ ) + } + + if (error) { + return + } + + if (items.length === 0) { + return + } + const onSortClick = (column: TableColumn) => { if (!column.sortField) { return @@ -101,7 +120,7 @@ export const Table = ({ /> - + {items.map((item, rowIndex) => ( {selectable && ( @@ -130,12 +149,6 @@ export const Table = ({ ))} - {isLoading && ( -
- -
- )} - {!isLoading && items.length === 0 && }
{ const { projectId } = useParams() @@ -35,5 +37,5 @@ export const CaptureGallery = ({ [captures, projectId] ) - return + return } diff --git a/ui/src/pages/collection-details/collection-details.module.scss b/ui/src/pages/collection-details/collection-details.module.scss deleted file mode 100644 index 671955ba9..000000000 --- a/ui/src/pages/collection-details/collection-details.module.scss +++ /dev/null @@ -1,11 +0,0 @@ -@import 'src/design-system/variables/variables.scss'; - -.galleryContent { - padding-top: 32px; -} - -@media only screen and (max-width: $small-screen-breakpoint) { - .galleryContent { - padding-top: 16px; - } -} diff --git a/ui/src/pages/collection-details/collection-details.tsx b/ui/src/pages/collection-details/collection-details.tsx index 27dacd4b1..709c909a7 100644 --- a/ui/src/pages/collection-details/collection-details.tsx +++ b/ui/src/pages/collection-details/collection-details.tsx @@ -7,7 +7,6 @@ import { PaginationBar } from 'design-system/components/pagination-bar/paginatio import { Table } from 'design-system/components/table/table/table' import { TableSortSettings } from 'design-system/components/table/types' import { ToggleGroup } from 'design-system/components/toggle-group/toggle-group' -import { Error } from 'pages/error/error' import { useContext, useEffect, useState } from 'react' import { useParams } from 'react-router-dom' import { BreadcrumbContext } from 'utils/breadcrumbContext' @@ -16,7 +15,6 @@ import { usePagination } from 'utils/usePagination' import { useSelectedView } from 'utils/useSelectedView' import { columns } from './capture-columns' import { CaptureGallery } from './capture-gallery' -import styles from './collection-details.module.scss' export const CollectionDetails = () => { const { projectId, id } = useParams() @@ -49,10 +47,6 @@ export const CollectionDetails = () => { ], }) - if (!isLoading && error) { - return - } - return ( <> {collection && ( @@ -84,18 +78,21 @@ export const CollectionDetails = () => { )} {selectedView === 'table' && ( )} {selectedView === 'gallery' && ( -
- -
+ )} {captures?.length ? ( diff --git a/ui/src/pages/deployments/deployments.tsx b/ui/src/pages/deployments/deployments.tsx index d9bad6654..182e6939c 100644 --- a/ui/src/pages/deployments/deployments.tsx +++ b/ui/src/pages/deployments/deployments.tsx @@ -3,7 +3,6 @@ import { PageHeader } from 'design-system/components/page-header/page-header' import { Table } from 'design-system/components/table/table/table' import { DeploymentDetailsDialog } from 'pages/deployment-details/deployment-details-dialog' import { NewDeploymentDialog } from 'pages/deployment-details/new-deployment-dialog' -import { Error } from 'pages/error/error' import { useParams } from 'react-router-dom' import { STRING, translate } from 'utils/language' import { useClientSideSort } from 'utils/useClientSideSort' @@ -23,10 +22,6 @@ export const Deployments = () => { }) const canCreate = userPermissions?.includes(UserPermission.Create) - if (!isLoading && error) { - return - } - return ( <> { {canCreate ? : null}
{id ? : null} diff --git a/ui/src/pages/error/error.module.scss b/ui/src/pages/error/error.module.scss deleted file mode 100644 index 8ebf63c29..000000000 --- a/ui/src/pages/error/error.module.scss +++ /dev/null @@ -1,19 +0,0 @@ -@import 'src/design-system/variables/colors.scss'; -@import 'src/design-system/variables/typography.scss'; - -.message { - @include heading-6(); - margin: 12px 0; - color: $color-neutral-300; -} - -.details { - display: block; - @include paragraph-small(); - font-family: monospace; - padding: 24px; - color: $color-neutral-300; - background: $color-generic-white; - border-radius: 8px; - border: 1px solid $color-neutral-100; -} diff --git a/ui/src/pages/error/error.tsx b/ui/src/pages/error/error.tsx deleted file mode 100644 index 5bd57235f..000000000 --- a/ui/src/pages/error/error.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import styles from './error.module.scss' - -interface ErrorProps { - message?: string - error?: any -} - -export const Error = ({ - message = 'Something went wrong!', - error, -}: ErrorProps) => { - const details = - error?.response?.data?.detail ?? error?.message ?? 'Unknown error' - - return ( -
-

{message} 🪲

- {details} -
- ) -} diff --git a/ui/src/pages/jobs/jobs.tsx b/ui/src/pages/jobs/jobs.tsx index caa5d0ae3..36bfa11b2 100644 --- a/ui/src/pages/jobs/jobs.tsx +++ b/ui/src/pages/jobs/jobs.tsx @@ -9,7 +9,6 @@ import { PaginationBar } from 'design-system/components/pagination-bar/paginatio import { ColumnSettings } from 'design-system/components/table/column-settings/column-settings' import { Table } from 'design-system/components/table/table/table' import _ from 'lodash' -import { Error } from 'pages/error/error' import { JobDetails } from 'pages/job-details/job-details' import { NewJobDialog } from 'pages/job-details/new-job-dialog' import { useContext, useEffect } from 'react' @@ -46,10 +45,6 @@ export const Jobs = () => { }) const canCreate = userPermissions?.includes(UserPermission.Create) - if (!isLoading && error) { - return - } - return (
@@ -80,11 +75,12 @@ export const Jobs = () => { {canCreate ? : null}
!!columnSettings[column.id] )} + error={error} + items={jobs} + isLoading={!id && isLoading} sortable sortSettings={sort} onSortSettingsChange={setSort} diff --git a/ui/src/pages/occurrences/occurrence-gallery.tsx b/ui/src/pages/occurrences/occurrence-gallery.tsx index f58f2414f..f44bc8d6a 100644 --- a/ui/src/pages/occurrences/occurrence-gallery.tsx +++ b/ui/src/pages/occurrences/occurrence-gallery.tsx @@ -6,11 +6,13 @@ import { APP_ROUTES } from 'utils/constants' import { getAppRoute } from 'utils/getAppRoute' export const OccurrenceGallery = ({ - occurrences = [], + error, isLoading, + occurrences = [], }: { - occurrences?: Occurrence[] + error?: any isLoading: boolean + occurrences?: Occurrence[] }) => { const { projectId } = useParams() @@ -32,5 +34,5 @@ export const OccurrenceGallery = ({ [occurrences, projectId] ) - return + return } diff --git a/ui/src/pages/occurrences/occurrences.module.scss b/ui/src/pages/occurrences/occurrences.module.scss index 9dfdf09f7..745fa4784 100644 --- a/ui/src/pages/occurrences/occurrences.module.scss +++ b/ui/src/pages/occurrences/occurrences.module.scss @@ -1,10 +1,6 @@ @import 'src/design-system/variables/colors.scss'; @import 'src/design-system/variables/typography.scss'; -.galleryContent { - padding-top: 24px; -} - .taxonCell { .taxonCellContent { display: flex; @@ -35,9 +31,3 @@ color: $color-neutral-700; } } - -@media only screen and (max-width: $small-screen-breakpoint) { - .galleryContent { - padding-top: 16px; - } -} diff --git a/ui/src/pages/occurrences/occurrences.tsx b/ui/src/pages/occurrences/occurrences.tsx index bcdd56f7e..71cda8a1c 100644 --- a/ui/src/pages/occurrences/occurrences.tsx +++ b/ui/src/pages/occurrences/occurrences.tsx @@ -12,7 +12,6 @@ import { PaginationBar } from 'design-system/components/pagination-bar/paginatio import { ColumnSettings } from 'design-system/components/table/column-settings/column-settings' import { Table } from 'design-system/components/table/table/table' import { ToggleGroup } from 'design-system/components/toggle-group/toggle-group' -import { Error } from 'pages/error/error' import { OccurrenceDetails } from 'pages/occurrence-details/occurrence-details' import { useContext, useEffect, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' @@ -30,7 +29,6 @@ import { useSort } from 'utils/useSort' import { OccurrenceActions } from './occurrence-actions' import { columns } from './occurrence-columns' import { OccurrenceGallery } from './occurrence-gallery' -import styles from './occurrences.module.scss' export const Occurrences = () => { const { user } = useUser() @@ -70,10 +68,6 @@ export const Occurrences = () => { ) const { selectedView, setSelectedView } = useSelectedView('table') - if (!isLoading && error) { - return - } - return ( <>
@@ -135,27 +129,27 @@ export const Occurrences = () => { {selectedView === 'table' && (
!!columnSettings[column.id])} - sortable - sortSettings={sort} - selectable={user.loggedIn} - selectedItems={selectedItems} + error={error} + isLoading={!id && isLoading} + items={occurrences} onSelectedItemsChange={setSelectedItems} onSortSettingsChange={setSort} + selectable={user.loggedIn} + selectedItems={selectedItems} + sortable + sortSettings={sort} /> )} {selectedView === 'gallery' && ( -
- -
+ )} diff --git a/ui/src/pages/overview/collections/collections.tsx b/ui/src/pages/overview/collections/collections.tsx index a0b081a98..4c1ddd99b 100644 --- a/ui/src/pages/overview/collections/collections.tsx +++ b/ui/src/pages/overview/collections/collections.tsx @@ -4,7 +4,6 @@ import { PageHeader } from 'design-system/components/page-header/page-header' import { PaginationBar } from 'design-system/components/pagination-bar/pagination-bar' import { Table } from 'design-system/components/table/table/table' import { TableSortSettings } from 'design-system/components/table/types' -import { Error } from 'pages/error/error' import { NewEntityDialog } from 'pages/overview/entities/new-entity-dialog' import { useState } from 'react' import { useParams } from 'react-router-dom' @@ -28,10 +27,6 @@ export const Collections = () => { }) const canCreate = userPermissions?.includes(UserPermission.Create) - if (!isLoading && error) { - return - } - return ( <> { )}
{collections?.length ? ( - } - return ( <>
{entities?.length ? ( { const { selectedView, setSelectedView } = useSelectedView('summary') @@ -26,7 +26,7 @@ export const Overview = () => { }>() if (!isLoading && error) { - return + return } if (isLoading || !project) { diff --git a/ui/src/pages/overview/pipelines/pipelines.tsx b/ui/src/pages/overview/pipelines/pipelines.tsx index 88a6c3031..173d37db1 100644 --- a/ui/src/pages/overview/pipelines/pipelines.tsx +++ b/ui/src/pages/overview/pipelines/pipelines.tsx @@ -3,7 +3,6 @@ import { PageHeader } from 'design-system/components/page-header/page-header' import { PaginationBar } from 'design-system/components/pagination-bar/pagination-bar' import { Table } from 'design-system/components/table/table/table' import { TableSortSettings } from 'design-system/components/table/types' -import { Error } from 'pages/error/error' import { useState } from 'react' import { useParams } from 'react-router-dom' import { STRING, translate } from 'utils/language' @@ -23,10 +22,6 @@ export const Pipelines = () => { sort, }) - if (!isLoading && error) { - return - } - return ( <> { tooltip={translate(STRING.TOOLTIP_PIPELINE)} />
{pipelines?.length ? ( { }) const canCreate = userPermissions?.includes(UserPermission.Create) - if (!isLoading && error) { - return - } - return ( <> { )} -
{items?.length ? ( ) : error ? (
- +
) : null} diff --git a/ui/src/pages/project-details/edit-project-dialog.tsx b/ui/src/pages/project-details/edit-project-dialog.tsx index 71d0790a0..c74c06f67 100644 --- a/ui/src/pages/project-details/edit-project-dialog.tsx +++ b/ui/src/pages/project-details/edit-project-dialog.tsx @@ -1,9 +1,9 @@ +import { ErrorState } from 'components/error-state/error-state' import { useProjectDetails } from 'data-services/hooks/projects/useProjectDetails' import { useUpdateProject } from 'data-services/hooks/projects/useUpdateProject' import * as Dialog from 'design-system/components/dialog/dialog' import { IconButton } from 'design-system/components/icon-button/icon-button' import { IconType } from 'design-system/components/icon/icon' -import { Error } from 'pages/error/error' import { useEffect, useState } from 'react' import { STRING, translate } from 'utils/language' import { ProjectDetailsForm } from './project-details-form' @@ -66,7 +66,7 @@ const EditProjectDialogContent = ({ /> ) : loadError ? (
- +
) : null} diff --git a/ui/src/pages/projects/project-gallery.tsx b/ui/src/pages/projects/project-gallery.tsx index 267b57edb..420fb3cee 100644 --- a/ui/src/pages/projects/project-gallery.tsx +++ b/ui/src/pages/projects/project-gallery.tsx @@ -11,11 +11,13 @@ import { STRING, translate } from 'utils/language' import styles from './projects.module.scss' export const ProjectGallery = ({ - projects = [], + error, isLoading, + projects = [], }: { - projects?: Project[] + error?: any isLoading: boolean + projects?: Project[] }) => { const navigate = useNavigate() const items = useMemo( @@ -36,6 +38,7 @@ export const ProjectGallery = ({ return ( { useProjects({ pagination }) const canCreate = userPermissions?.includes(UserPermission.Create) - if (!isLoading && error) { - return - } - return ( <> { {canCreate && }
- +
{projects?.length ? ( diff --git a/ui/src/pages/session-details/session-details.tsx b/ui/src/pages/session-details/session-details.tsx index c48b70307..6f5714e4d 100644 --- a/ui/src/pages/session-details/session-details.tsx +++ b/ui/src/pages/session-details/session-details.tsx @@ -1,10 +1,10 @@ +import { ErrorState } from 'components/error-state/error-state' import { FetchInfo } from 'components/fetch-info/fetch-info' import { useSessionDetails } from 'data-services/hooks/sessions/useSessionDetails' import { Box } from 'design-system/components/box/box' import { LoadingSpinner } from 'design-system/components/loading-spinner/loading-spinner' import { PlotGrid } from 'design-system/components/plot-grid/plot-grid' import { Plot } from 'design-system/components/plot/lazy-plot' -import { Error } from 'pages/error/error' import { useContext, useEffect } from 'react' import { Helmet } from 'react-helmet-async' import { useParams } from 'react-router-dom' @@ -42,7 +42,7 @@ export const SessionDetails = () => { } if (!session || error) { - return + return } return ( diff --git a/ui/src/pages/sessions/session-gallery.tsx b/ui/src/pages/sessions/session-gallery.tsx index 276b3f7ca..382d85638 100644 --- a/ui/src/pages/sessions/session-gallery.tsx +++ b/ui/src/pages/sessions/session-gallery.tsx @@ -5,9 +5,11 @@ import { useParams } from 'react-router-dom' import { APP_ROUTES } from 'utils/constants' export const SessionGallery = ({ + error, sessions = [], isLoading, }: { + error?: any sessions?: Session[] isLoading: boolean }) => { @@ -27,5 +29,5 @@ export const SessionGallery = ({ [sessions, projectId] ) - return + return } diff --git a/ui/src/pages/sessions/sessions.module.scss b/ui/src/pages/sessions/sessions.module.scss deleted file mode 100644 index e8a4c4f15..000000000 --- a/ui/src/pages/sessions/sessions.module.scss +++ /dev/null @@ -1,11 +0,0 @@ -@import 'src/design-system/variables/variables.scss'; - -.galleryContent { - padding-top: 24px; -} - -@media only screen and (max-width: $small-screen-breakpoint) { - .galleryContent { - padding-top: 16px; - } -} diff --git a/ui/src/pages/sessions/sessions.tsx b/ui/src/pages/sessions/sessions.tsx index 646c01369..a91552b7c 100644 --- a/ui/src/pages/sessions/sessions.tsx +++ b/ui/src/pages/sessions/sessions.tsx @@ -8,7 +8,6 @@ import { PaginationBar } from 'design-system/components/pagination-bar/paginatio import { ColumnSettings } from 'design-system/components/table/column-settings/column-settings' import { Table } from 'design-system/components/table/table/table' import { ToggleGroup } from 'design-system/components/toggle-group/toggle-group' -import { Error } from 'pages/error/error' import { useParams } from 'react-router-dom' import { STRING, translate } from 'utils/language' import { useColumnSettings } from 'utils/useColumnSettings' @@ -18,7 +17,6 @@ import { useSelectedView } from 'utils/useSelectedView' import { useSort } from 'utils/useSort' import { columns } from './session-columns' import { SessionGallery } from './session-gallery' -import styles from './sessions.module.scss' export const Sessions = () => { const { projectId } = useParams() @@ -46,10 +44,6 @@ export const Sessions = () => { }) const { selectedView, setSelectedView } = useSelectedView('table') - if (!isLoading && error) { - return - } - return ( <>
@@ -90,6 +84,7 @@ export const Sessions = () => { {selectedView === 'table' && (
{ /> )} {selectedView === 'gallery' && ( -
- -
+ )} diff --git a/ui/src/pages/species/species-gallery.tsx b/ui/src/pages/species/species-gallery.tsx index 35ab15ca8..35af26123 100644 --- a/ui/src/pages/species/species-gallery.tsx +++ b/ui/src/pages/species/species-gallery.tsx @@ -6,11 +6,13 @@ import { APP_ROUTES } from 'utils/constants' import { getAppRoute } from 'utils/getAppRoute' export const SpeciesGallery = ({ - species = [], + error, isLoading, + species = [], }: { - species?: Species[] + error?: any isLoading: boolean + species?: Species[] }) => { const { projectId } = useParams() @@ -31,5 +33,5 @@ export const SpeciesGallery = ({ [species] ) - return + return } diff --git a/ui/src/pages/species/species.module.scss b/ui/src/pages/species/species.module.scss deleted file mode 100644 index e8a4c4f15..000000000 --- a/ui/src/pages/species/species.module.scss +++ /dev/null @@ -1,11 +0,0 @@ -@import 'src/design-system/variables/variables.scss'; - -.galleryContent { - padding-top: 24px; -} - -@media only screen and (max-width: $small-screen-breakpoint) { - .galleryContent { - padding-top: 16px; - } -} diff --git a/ui/src/pages/species/species.tsx b/ui/src/pages/species/species.tsx index 5b0a99e3d..5c91b6ffb 100644 --- a/ui/src/pages/species/species.tsx +++ b/ui/src/pages/species/species.tsx @@ -9,7 +9,6 @@ import { PageHeader } from 'design-system/components/page-header/page-header' import { PaginationBar } from 'design-system/components/pagination-bar/pagination-bar' import { Table } from 'design-system/components/table/table/table' import { ToggleGroup } from 'design-system/components/toggle-group/toggle-group' -import { Error } from 'pages/error/error' import { SpeciesDetails } from 'pages/species-details/species-details' import { useContext, useEffect } from 'react' import { useNavigate, useParams } from 'react-router-dom' @@ -23,7 +22,6 @@ import { useSelectedView } from 'utils/useSelectedView' import { useSort } from 'utils/useSort' import { columns } from './species-columns' import { SpeciesGallery } from './species-gallery' -import styles from './species.module.scss' export const Species = () => { const { projectId, id } = useParams() @@ -38,10 +36,6 @@ export const Species = () => { }) const { selectedView, setSelectedView } = useSelectedView('table') - if (!isLoading && error) { - return - } - return ( <>
@@ -78,18 +72,21 @@ export const Species = () => { {selectedView === 'table' && (
)} {selectedView === 'gallery' && ( -
- -
+ )} diff --git a/ui/src/utils/useFilters.ts b/ui/src/utils/useFilters.ts index d277401ce..c34375da9 100644 --- a/ui/src/utils/useFilters.ts +++ b/ui/src/utils/useFilters.ts @@ -1,6 +1,14 @@ +import { isBefore, isValid } from 'date-fns' import { useSearchParams } from 'react-router-dom' -export const AVAILABLE_FILTERS = [ +export const AVAILABLE_FILTERS: { + label: string + field: string + validate?: ( + value?: string, + filters?: { field: string; value?: string }[] + ) => string | undefined +}[] = [ { label: 'Include algorithm', field: 'algorithm', @@ -24,10 +32,40 @@ export const AVAILABLE_FILTERS = [ { label: 'End date', field: 'date_end', + validate: (value) => { + if (!value) { + return undefined + } + + if (!isValid(new Date(value))) { + return 'Date is not valid' + } + + return undefined + }, }, { label: 'Start date', field: 'date_start', + validate: (value, filters) => { + if (!value) { + return undefined + } + + if (!isValid(new Date(value))) { + return 'Date is not valid' + } + + const dateEnd = filters?.find( + (filter) => filter.field === 'date_end' + )?.value + + if (dateEnd && !isBefore(new Date(value), new Date(dateEnd))) { + return 'Start date must be before end date' + } + + return undefined + }, }, { label: 'Source image', @@ -74,16 +112,29 @@ export const AVAILABLE_FILTERS = [ export const useFilters = (defaultFilters?: { [field: string]: string }) => { const [searchParams, setSearchParams] = useSearchParams() - const filters = AVAILABLE_FILTERS.map((filter) => { - const value = searchParams.getAll(filter.field)[0] + const _filters = AVAILABLE_FILTERS.map(({ field, ...rest }) => { + const value = searchParams.get(field) ?? defaultFilters?.[field] + + return { + ...rest, + field, + value, + } + }) + + const filters = _filters.map(({ validate, value, ...rest }) => { + const error = validate ? validate(value, _filters) : undefined + const isValid = !error return { - ...filter, - value: value ?? defaultFilters?.[filter.field], + ...rest, + value, + isValid, + error, } }) - const activeFilters = filters.filter((filter) => filter.value?.length) + const activeFilters = filters.filter((filter) => !!filter.value?.length) const addFilter = (field: string, value: string) => { if (AVAILABLE_FILTERS.some((filter) => filter.field === field)) { From 894eb4d64eccfd3de1966c93e5e76c3d2e35c6ea Mon Sep 17 00:00:00 2001 From: Anna Viklund Date: Thu, 19 Dec 2024 06:42:14 +0100 Subject: [PATCH 11/11] Make it easier to go to prev/next occurrence (#659) * feat: add buttons for navigating to prev and next occurrence * feat: add keyboard quick actions for occurrence navigation * fix: define keyboard quick actions exceptions * fix: scroll current occurrence into view view on navigate * fix: cleanup --- ui/src/app.module.scss | 1 + ui/src/components/gallery/gallery.tsx | 13 +- ui/src/design-system/components/card/card.tsx | 4 +- .../components/dialog/dialog.module.scss | 7 +- .../form-stepper/form-stepper.module.scss | 2 +- .../pages/occurrences/occurrence-columns.tsx | 5 +- .../occurrences/occurrence-navigation.tsx | 112 ++++++++++++++++++ ui/src/pages/occurrences/occurrences.tsx | 27 ++++- 8 files changed, 156 insertions(+), 15 deletions(-) create mode 100644 ui/src/pages/occurrences/occurrence-navigation.tsx diff --git a/ui/src/app.module.scss b/ui/src/app.module.scss index 1a947405f..058e34594 100644 --- a/ui/src/app.module.scss +++ b/ui/src/app.module.scss @@ -9,6 +9,7 @@ height: 100dvh; overflow-x: auto; overflow-y: auto; + scroll-padding: 64px 0; } .main { diff --git a/ui/src/components/gallery/gallery.tsx b/ui/src/components/gallery/gallery.tsx index ecf931965..b21a125e0 100644 --- a/ui/src/components/gallery/gallery.tsx +++ b/ui/src/components/gallery/gallery.tsx @@ -60,21 +60,22 @@ export const Gallery = ({ (item) => renderItem?.(item) ?? (item.to ? ( - + ) : ( )) )} diff --git a/ui/src/design-system/components/card/card.tsx b/ui/src/design-system/components/card/card.tsx index 700bab9cb..15e9ee74c 100644 --- a/ui/src/design-system/components/card/card.tsx +++ b/ui/src/design-system/components/card/card.tsx @@ -11,6 +11,7 @@ export enum CardSize { interface CardProps { children?: ReactNode + id?: string image?: { src: string alt?: string @@ -24,6 +25,7 @@ interface CardProps { export const Card = ({ children, + id, image, maxWidth, size = CardSize.Medium, @@ -32,7 +34,7 @@ export const Card = ({ to, }: CardProps) => { return ( -
+
{image ? ( to ? ( diff --git a/ui/src/design-system/components/dialog/dialog.module.scss b/ui/src/design-system/components/dialog/dialog.module.scss index bf7b508eb..4faa6497a 100644 --- a/ui/src/design-system/components/dialog/dialog.module.scss +++ b/ui/src/design-system/components/dialog/dialog.module.scss @@ -36,11 +36,7 @@ $dialog-padding-medium: 32px; max-width: calc(100% - (2 * $dialog-padding-large)); height: calc(100vh - (2 * $dialog-padding-large)); height: calc(100dvh - (2 * $dialog-padding-large)); - border-radius: 4px; - background-color: $color-generic-white; - box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.1); z-index: 3; - overflow: hidden; &.compact { height: fit-content; @@ -54,6 +50,9 @@ $dialog-padding-medium: 32px; .dialogContent { height: inherit; max-width: 100%; + border-radius: 4px; + background-color: $color-generic-white; + box-shadow: 0px 4px 16px rgba(0, 0, 0, 0.1); overflow-y: auto; overflow-x: auto; } diff --git a/ui/src/design-system/components/form-stepper/form-stepper.module.scss b/ui/src/design-system/components/form-stepper/form-stepper.module.scss index 39065bbc2..a42937b61 100644 --- a/ui/src/design-system/components/form-stepper/form-stepper.module.scss +++ b/ui/src/design-system/components/form-stepper/form-stepper.module.scss @@ -37,7 +37,7 @@ height: 2px; width: 100%; background-color: $color-neutral-100; - z-index: -1; + z-index: 0; } .circle { diff --git a/ui/src/pages/occurrences/occurrence-columns.tsx b/ui/src/pages/occurrences/occurrence-columns.tsx index 486539191..0ba090799 100644 --- a/ui/src/pages/occurrences/occurrence-columns.tsx +++ b/ui/src/pages/occurrences/occurrence-columns.tsx @@ -60,6 +60,7 @@ export const columns: ( sortField: 'determination__name', renderCell: (item: Occurrence) => ( +
diff --git a/ui/src/pages/occurrences/occurrence-navigation.tsx b/ui/src/pages/occurrences/occurrence-navigation.tsx new file mode 100644 index 000000000..f228dd47f --- /dev/null +++ b/ui/src/pages/occurrences/occurrence-navigation.tsx @@ -0,0 +1,112 @@ +import { Occurrence } from 'data-services/models/occurrence' +import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react' +import { Button } from 'nova-ui-kit' +import { useCallback, useEffect } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { APP_ROUTES } from 'utils/constants' +import { getAppRoute } from 'utils/getAppRoute' + +const useOccurrenceNavigation = (occurrences?: Occurrence[]) => { + const { projectId, id } = useParams() + const navigate = useNavigate() + const currentIndex = occurrences?.findIndex((o) => o.id === id) + const prevId = + currentIndex !== undefined ? occurrences?.[currentIndex - 1]?.id : undefined + const nextId = + currentIndex !== undefined ? occurrences?.[currentIndex + 1]?.id : undefined + + const goToPrev = useCallback(() => { + if (!prevId) { + return + } + + navigate( + getAppRoute({ + to: APP_ROUTES.OCCURRENCE_DETAILS({ + projectId: projectId as string, + occurrenceId: prevId, + }), + keepSearchParams: true, + }) + ) + }, [nextId]) + + const goToNext = useCallback(() => { + if (!nextId) { + return + } + + navigate( + getAppRoute({ + to: APP_ROUTES.OCCURRENCE_DETAILS({ + projectId: projectId as string, + occurrenceId: nextId, + }), + keepSearchParams: true, + }) + ) + }, [nextId]) + + return { + prevId, + nextId, + goToPrev, + goToNext, + } +} + +export const OccurrenceNavigation = ({ + occurrences, +}: { + occurrences?: Occurrence[] +}) => { + const { prevId, nextId, goToPrev, goToNext } = + useOccurrenceNavigation(occurrences) + + // Listen to key down events + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if ( + document.activeElement?.matches('input') || + document.activeElement?.role === 'tab' + ) { + return + } + + if (e.key === 'ArrowLeft') { + e.preventDefault() + goToPrev() + } else if (e.key === 'ArrowRight') { + e.preventDefault() + goToNext() + } + } + + document.addEventListener('keydown', onKeyDown) + + return () => document.removeEventListener('keydown', onKeyDown) + }, [goToPrev, goToNext]) + + return ( + <> + + + + ) +} diff --git a/ui/src/pages/occurrences/occurrences.tsx b/ui/src/pages/occurrences/occurrences.tsx index 71cda8a1c..4f0f83470 100644 --- a/ui/src/pages/occurrences/occurrences.tsx +++ b/ui/src/pages/occurrences/occurrences.tsx @@ -3,6 +3,7 @@ import { FilterSection } from 'components/filtering/filter-section' import { someActive } from 'components/filtering/utils' import { useOccurrenceDetails } from 'data-services/hooks/occurrences/useOccurrenceDetails' import { useOccurrences } from 'data-services/hooks/occurrences/useOccurrences' +import { Occurrence } from 'data-services/models/occurrence' import { BulkActionBar } from 'design-system/components/bulk-action-bar/bulk-action-bar' import * as Dialog from 'design-system/components/dialog/dialog' import { IconType } from 'design-system/components/icon/icon' @@ -29,6 +30,7 @@ import { useSort } from 'utils/useSort' import { OccurrenceActions } from './occurrence-actions' import { columns } from './occurrence-columns' import { OccurrenceGallery } from './occurrence-gallery' +import { OccurrenceNavigation } from './occurrence-navigation' export const Occurrences = () => { const { user } = useUser() @@ -68,6 +70,18 @@ export const Occurrences = () => { ) const { selectedView, setSelectedView } = useSelectedView('table') + useEffect(() => { + document.getElementById('app')?.scrollTo({ top: 0 }) + }, [pagination.page]) + + useEffect(() => { + if (id) { + document + .getElementById(id) + ?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + } + }, [id]) + return ( <>
@@ -181,12 +195,20 @@ export const Occurrences = () => { /> ) : null} - {id ? : null} + {id ? ( + + ) : null} ) } -const OccurrenceDetailsDialog = ({ id }: { id: string }) => { +const OccurrenceDetailsDialog = ({ + id, + occurrences, +}: { + id: string + occurrences?: Occurrence[] +}) => { const navigate = useNavigate() const { projectId } = useParams() const { setDetailBreadcrumb } = useContext(BreadcrumbContext) @@ -220,6 +242,7 @@ const OccurrenceDetailsDialog = ({ id }: { id: string }) => { error={error} > {occurrence ? : null} + )