diff --git a/frontend/public/images/MatchResultExample.png b/frontend/public/images/MatchResultExample.png new file mode 100644 index 0000000000..127e15e9d1 Binary files /dev/null and b/frontend/public/images/MatchResultExample.png differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e07d882639..0b9f83c4c7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useRef } from "react"; import { IntlProvider } from "react-intl"; import messagesEn from "./locale/en.json"; import messagesEs from "./locale/es.json"; @@ -14,7 +14,9 @@ import LocaleContext from "./IntlProvider"; import FooterVisibilityContext from "./FooterVisibilityContext"; import Cookies from "js-cookie"; import FilterContext from "./FilterContextProvider"; +import { SiteSettingsProvider } from "./SiteSettingsContext"; import { ToastContainer } from "react-toastify"; +import "react-toastify/dist/ReactToastify.css"; function App() { const messageMap = { @@ -24,16 +26,20 @@ function App() { it: messagesIt, de: messagesDe, }; + const initialLocale = Cookies.get("wildbookLangCode") || "en"; const [locale, setLocale] = useState(initialLocale); + const [visible, setVisible] = useState(true); + const containerStyle = { display: "flex", flexDirection: "column", minHeight: "100vh", }; - const queryClient = new QueryClient(); + const queryClientRef = useRef(null); + if (!queryClientRef.current) queryClientRef.current = new QueryClient(); const handleLocaleChange = (newLocale) => { setLocale(newLocale); @@ -59,7 +65,7 @@ function App() { : "/"; return ( - + @@ -73,27 +79,32 @@ function App() { defaultLocale="en" messages={messageMap[locale]} > - - + - - - - + + + + + + + diff --git a/frontend/src/AuthenticatedSwitch.jsx b/frontend/src/AuthenticatedSwitch.jsx index e09a3cd6a6..453dd30b36 100644 --- a/frontend/src/AuthenticatedSwitch.jsx +++ b/frontend/src/AuthenticatedSwitch.jsx @@ -11,7 +11,6 @@ const AboutUs = lazy(() => import("./pages/AboutUs")); // Lazy load pages const Login = lazy(() => import("./pages/Login")); -const Profile = lazy(() => import("./pages/Profile")); const Home = lazy(() => import("./pages/Home")); const EncounterSearch = lazy( () => import("./pages/SearchPages/EncounterSearch"), @@ -29,8 +28,12 @@ const EditAnnotation = lazy(() => import("./pages/EditAnnotation")); const BulkImport = lazy(() => import("./pages/BulkImport/BulkImport")); const BulkImportTask = lazy(() => import("./pages/BulkImport/BulkImportTask")); +const MatchResults = lazy( + () => import("./pages/MatchResultsPage/MatchResults"), +); const Encounter = lazy(() => import("./pages/Encounter/Encounter")); +const Citation = lazy(() => import("./pages/Citation")); const PoliciesAndData = lazy( () => import("./pages/PoliciesAndData/PoliciesAndData"), ); @@ -80,7 +83,8 @@ export default function AuthenticatedSwitch({ > Loading...}> - } /> + } /> + } /> } /> { + const { data, isLoading, error } = useGetSiteSettings(); + return ( + + {children} + + ); +}; + +export const useSiteSettings = () => { + const context = useContext(SiteSettingsContext); + if (context === null) { + throw new Error("useSiteSettings must be used within SiteSettingsProvider"); + } + return context; +}; diff --git a/frontend/src/__tests__/FrontDesk.test.js b/frontend/src/__tests__/FrontDesk.test.js index 5405fbcaf4..8550923c4e 100644 --- a/frontend/src/__tests__/FrontDesk.test.js +++ b/frontend/src/__tests__/FrontDesk.test.js @@ -2,7 +2,7 @@ import React from "react"; import { render, screen, waitFor } from "@testing-library/react"; import axios from "axios"; import FrontDesk from "../FrontDesk"; -import useGetSiteSettings from "../models/useGetSiteSettings"; +import { useSiteSettings } from "../SiteSettingsContext"; jest.mock("../AuthenticatedSwitch", () => { const MockComponent = () =>
Authenticated
; @@ -35,7 +35,7 @@ jest.mock("../components/SessionWarning", () => { }); jest.mock("../hooks/useDocumentTitle", () => jest.fn()); -jest.mock("../models/useGetSiteSettings"); +jest.mock("../SiteSettingsContext", () => ({ useSiteSettings: jest.fn() })); jest.mock("../models/notifications/getMergeNotifications", () => jest.fn()); jest.mock("../models/notifications/getCollaborationNotifications", () => jest.fn(), @@ -46,7 +46,11 @@ jest.mock("axios"); describe("FrontDesk Component", () => { beforeEach(() => { jest.clearAllMocks(); - useGetSiteSettings.mockReturnValue({ data: { showClassicSubmit: false } }); + useSiteSettings.mockReturnValue({ + data: { showClassicSubmit: false }, + isLoading: false, + error: null, + }); }); test("renders UnauthenticatedSwitch if user is not logged in (401)", async () => { @@ -65,7 +69,11 @@ describe("FrontDesk Component", () => { test("keeps loading on non-401 login check failure (current behavior)", async () => { const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); axios.head.mockRejectedValueOnce(new Error("Network Error")); - useGetSiteSettings.mockReturnValue({ data: {} }); + useSiteSettings.mockReturnValue({ + data: {}, + isLoading: false, + error: null, + }); render(); diff --git a/frontend/src/__tests__/components/AuthenticatedSwitch.test.js b/frontend/src/__tests__/components/AuthenticatedSwitch.test.js index 79d4cdb72b..fe1853a626 100644 --- a/frontend/src/__tests__/components/AuthenticatedSwitch.test.js +++ b/frontend/src/__tests__/components/AuthenticatedSwitch.test.js @@ -80,12 +80,6 @@ describe("AuthenticatedSwitch", () => { expect(await screen.findByText("Home Page")).toBeInTheDocument(); }); - test("renders the profile page when navigating to /profile", async () => { - window.history.pushState({}, "", "/profile"); - renderComponent({ showAlert: false, setShowAlert: jest.fn() }); - expect(await screen.findByText("Profile Page")).toBeInTheDocument(); - }); - test("renders the login page when navigating to /login", async () => { window.history.pushState({}, "", "/login"); renderComponent({ showAlert: false, setShowAlert: jest.fn() }); diff --git a/frontend/src/__tests__/components/Map.test.js b/frontend/src/__tests__/components/Map.test.js index d36333891f..c334a4ac36 100644 --- a/frontend/src/__tests__/components/Map.test.js +++ b/frontend/src/__tests__/components/Map.test.js @@ -1,9 +1,9 @@ import React from "react"; import { render, screen, fireEvent } from "@testing-library/react"; import MapComponent from "../../components/Map"; -import useGetSiteSettings from "../../models/useGetSiteSettings"; +import { useSiteSettings } from "../../SiteSettingsContext"; -jest.mock("../../models/useGetSiteSettings", () => jest.fn()); +jest.mock("../../SiteSettingsContext", () => ({ useSiteSettings: jest.fn() })); jest.mock("react-intl", () => { const OriginalModule = jest.requireActual("react-intl"); @@ -31,7 +31,11 @@ describe("MapComponent", () => { }); test("renders loading state when no googleMapsKey is present", () => { - useGetSiteSettings.mockReturnValue({ data: {} }); + useSiteSettings.mockReturnValue({ + data: {}, + isLoading: false, + error: null, + }); render(); @@ -39,14 +43,22 @@ describe("MapComponent", () => { }); test("renders the Draw button", () => { - useGetSiteSettings.mockReturnValue({ data: {} }); + useSiteSettings.mockReturnValue({ + data: {}, + isLoading: false, + error: null, + }); render(); expect(screen.getByRole("button")).toBeInTheDocument(); }); test("toggles button text between DRAW and CANCEL", () => { - useGetSiteSettings.mockReturnValue({ data: {} }); + useSiteSettings.mockReturnValue({ + data: {}, + isLoading: false, + error: null, + }); render(); const button = screen.getByRole("button"); @@ -63,7 +75,11 @@ describe("MapComponent", () => { test("calls setBounds and setTempBounds when drawing is toggled", () => { const setBoundsMock = jest.fn(); const setTempBoundsMock = jest.fn(); - useGetSiteSettings.mockReturnValue({ data: {} }); + useSiteSettings.mockReturnValue({ + data: {}, + isLoading: false, + error: null, + }); render( () => ({ +jest.mock("../../../SiteSettingsContext", () => ({ + useSiteSettings: jest.fn(), +})); + +useSiteSettings.mockReturnValue({ data: { bulkImportMinimalFields: {}, locationData: { locationID: ["loc1", "loc2"] }, @@ -57,7 +62,9 @@ jest.mock("../../../models/useGetSiteSettings", () => () => ({ key1: ["val1", "val2"], }, }, -})); + isLoading: false, + error: null, +}); describe("EditableDataTable", () => { test("renders the table with correct headers", () => { diff --git a/frontend/src/__tests__/pages/BulkImport/BulkImportImageUpload.test.js b/frontend/src/__tests__/pages/BulkImport/BulkImportImageUpload.test.js index 8083e71987..1339b27faf 100644 --- a/frontend/src/__tests__/pages/BulkImport/BulkImportImageUpload.test.js +++ b/frontend/src/__tests__/pages/BulkImport/BulkImportImageUpload.test.js @@ -1,10 +1,11 @@ import React from "react"; import { render, fireEvent, act } from "@testing-library/react"; import "@testing-library/jest-dom"; +import { useSiteSettings } from "../../../SiteSettingsContext"; -jest.mock("../../../models/useGetSiteSettings", () => ({ +jest.mock("../../../SiteSettingsContext", () => ({ __esModule: true, - default: () => ({ data: { maximumMediaSizeMegabytes: 5 } }), + useSiteSettings: jest.fn(), })); jest.mock("../../../pages/BulkImport/BulkImportImageUploadInfo", () => ({ __esModule: true, @@ -52,6 +53,11 @@ describe("BulkImportImageUpload", () => { }; beforeEach(() => { + useSiteSettings.mockReturnValue({ + data: { maximumMediaSizeMegabytes: 5 }, + isLoading: false, + error: null, + }); store = { filesParsed: false, imagePreview: [], diff --git a/frontend/src/__tests__/pages/BulkImport/BulkImportInstuctionsModal.test.js b/frontend/src/__tests__/pages/BulkImport/BulkImportInstuctionsModal.test.js index 6c9715327a..a3e195139f 100644 --- a/frontend/src/__tests__/pages/BulkImport/BulkImportInstuctionsModal.test.js +++ b/frontend/src/__tests__/pages/BulkImport/BulkImportInstuctionsModal.test.js @@ -1,10 +1,11 @@ import React from "react"; import { render, screen, fireEvent } from "@testing-library/react"; import BulkImportInstructionsModal from "../../../pages/BulkImport/BulkImportInstructionsModal"; +import { useSiteSettings } from "../../../SiteSettingsContext"; -jest.mock("../../../models/useGetSiteSettings", () => ({ +jest.mock("../../../SiteSettingsContext", () => ({ __esModule: true, - default: () => ({ data: null }), + useSiteSettings: jest.fn(), })); jest.mock("react-intl", () => ({ @@ -22,6 +23,11 @@ describe("BulkImportInstructionsModal (without changing the component)", () => { showInstructions: true, setShowInstructions: jest.fn(), }; + useSiteSettings.mockReturnValue({ + data: null, + isLoading: false, + error: null, + }); }); it("renders the Wildbook docs link in the NEED_HELP_DOCS step", () => { diff --git a/frontend/src/__tests__/pages/BulkImport/BulkImportTask.test.js b/frontend/src/__tests__/pages/BulkImport/BulkImportTask.test.js index a8326646bb..a8413ba3fb 100644 --- a/frontend/src/__tests__/pages/BulkImport/BulkImportTask.test.js +++ b/frontend/src/__tests__/pages/BulkImport/BulkImportTask.test.js @@ -3,6 +3,7 @@ import { screen, fireEvent, waitFor } from "@testing-library/react"; import { renderWithProviders } from "../../../utils/utils"; import BulkImportTask from "../../../pages/BulkImport/BulkImportTask"; import axios from "axios"; +import { useSiteSettings } from "../../../SiteSettingsContext"; jest.mock("antd/es/tree-select", () => ({ __esModule: true, @@ -10,6 +11,9 @@ jest.mock("antd/es/tree-select", () => ({ })); jest.mock("../../../models/bulkImport/useGetBulkImportTask", () => jest.fn()); +jest.mock("../../../SiteSettingsContext", () => ({ + useSiteSettings: jest.fn(), +})); jest.mock("../../../components/InfoAccordion", () => ({ __esModule: true, default: ({ title, data }) => { @@ -87,6 +91,11 @@ const mockTask = { describe("BulkImportTask", () => { beforeEach(() => { jest.clearAllMocks(); + useSiteSettings.mockReturnValue({ + data: {}, + isLoading: false, + error: null, + }); delete window.location; window.location = new URL("http://localhost/react/?id=12345"); axios.get.mockResolvedValue({ data: { roles: [] } }); diff --git a/frontend/src/__tests__/pages/Encounter/EditAnnotation.test.js b/frontend/src/__tests__/pages/Encounter/EditAnnotation.test.js index 3e886d9749..872f7bfc08 100644 --- a/frontend/src/__tests__/pages/Encounter/EditAnnotation.test.js +++ b/frontend/src/__tests__/pages/Encounter/EditAnnotation.test.js @@ -3,6 +3,7 @@ import React from "react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import axios from "axios"; +import { useSiteSettings } from "../../../SiteSettingsContext"; jest.mock("mobx-react-lite", () => ({ observer: (Comp) => Comp, @@ -67,16 +68,9 @@ jest.mock("../../../components/AnnotationSuccessful", () => (props) => (
SUCCESS
)); -jest.mock("../../../models/useGetSiteSettings", () => ({ +jest.mock("../../../SiteSettingsContext", () => ({ __esModule: true, - default: () => ({ - data: { - iaClassesForTaxonomy: { - "Panthera leo": ["head", "side"], - }, - annotationViewpoint: ["front", "left"], - }, - }), + useSiteSettings: jest.fn(), })); jest.mock("react-router-dom", () => ({ @@ -139,6 +133,16 @@ let selectClickCount = 0; describe("EditAnnotation", () => { beforeEach(() => { jest.clearAllMocks(); + useSiteSettings.mockReturnValue({ + data: { + iaClassesForTaxonomy: { + "Panthera leo": ["head", "side"], + }, + annotationViewpoint: ["front", "left"], + }, + isLoading: false, + error: null, + }); selectClickCount = 0; axios.get = jest.fn(); global.fetch = jest.fn().mockResolvedValue({ diff --git a/frontend/src/__tests__/pages/Encounter/Encounter.test.js b/frontend/src/__tests__/pages/Encounter/Encounter.test.js index edbc57164b..d6be041f80 100644 --- a/frontend/src/__tests__/pages/Encounter/Encounter.test.js +++ b/frontend/src/__tests__/pages/Encounter/Encounter.test.js @@ -4,6 +4,7 @@ import { act, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { IntlProvider } from "react-intl"; import axios from "axios"; +import { useSiteSettings } from "../../../SiteSettingsContext"; jest.mock("react", () => jest.requireActual("react")); @@ -16,12 +17,9 @@ jest.mock("axios", () => { return { __esModule: true, default: api, ...api }; }); -jest.mock("../../../models/useGetSiteSettings", () => ({ +jest.mock("../../../SiteSettingsContext", () => ({ __esModule: true, - default: () => ({ - data: { encounterState: ["unidentifiable", "identified", "rejected"] }, - loading: false, - }), + useSiteSettings: jest.fn(), })); jest.mock("../../../components/icons/DateIcon", () => () => ( @@ -254,6 +252,11 @@ const flushPromises = async () => { describe("Encounter page – stable behavior tests", () => { beforeEach(() => { + useSiteSettings.mockReturnValue({ + data: { encounterState: ["unidentifiable", "identified", "rejected"] }, + isLoading: false, + error: null, + }); jest.useRealTimers(); global.__MOCK_STORE_PRESET__ = undefined; global.__LAST_ENCOUNTER_STORE__ = undefined; diff --git a/frontend/src/__tests__/pages/Encounter/HelperFunctions.test.js b/frontend/src/__tests__/pages/Encounter/HelperFunctions.test.js index 8e90f0425d..2bb3416630 100644 --- a/frontend/src/__tests__/pages/Encounter/HelperFunctions.test.js +++ b/frontend/src/__tests__/pages/Encounter/HelperFunctions.test.js @@ -205,7 +205,9 @@ describe("parseYMDHM", () => { describe("expandOperations", () => { test("expands date operation to year/month/day/hour/minutes", () => { - const ops = [{ op: "replace", path: "dateValues", value: "2025-10-31T14:20" }]; + const ops = [ + { op: "replace", path: "dateValues", value: "2025-10-31T14:20" }, + ]; const out = expandOperations(ops); expect(out).toEqual([ diff --git a/frontend/src/__tests__/pages/Encounter/IdentifySectionEdit.test.js b/frontend/src/__tests__/pages/Encounter/IdentifySectionEdit.test.js index ee28e93f78..30acca07b6 100644 --- a/frontend/src/__tests__/pages/Encounter/IdentifySectionEdit.test.js +++ b/frontend/src/__tests__/pages/Encounter/IdentifySectionEdit.test.js @@ -145,7 +145,9 @@ describe("IdentifySectionEdit", () => { await props.loadOptions(" In-42 "); expect(store.searchIndividualsByNameAndId).toHaveBeenCalledTimes(1); - expect(store.searchIndividualsByNameAndId).toHaveBeenCalledWith(" In-42 "); + expect(store.searchIndividualsByNameAndId).toHaveBeenCalledWith( + " In-42 ", + ); }); test("INDIVIDUAL_ID loadOptions maps search results and sets individual options", async () => { @@ -273,4 +275,4 @@ describe("IdentifySectionEdit", () => { render(); expect(screen.getByRole("alert")).toHaveTextContent("e1;e2"); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/__tests__/pages/Encounter/ImageCard.test.js b/frontend/src/__tests__/pages/Encounter/ImageCard.test.js index ed1803c155..65f3f42155 100644 --- a/frontend/src/__tests__/pages/Encounter/ImageCard.test.js +++ b/frontend/src/__tests__/pages/Encounter/ImageCard.test.js @@ -301,9 +301,8 @@ describe("ImageCard", () => { await user.click(screen.getByText("MATCH_RESULTS")); expect(window.open).toHaveBeenCalledTimes(1); - expect(window.open.mock.calls[0][0]).toContain( - "/iaResults.jsp?taskId=TASK-99", - ); + const url = window.open.mock.calls[0][0]; + expect(url).toContain("/react/match-results?taskId=TASK-99"); }); test("MATCH_RESULTS for foreign annotation fetches encounter and opens iaResults if available", async () => { @@ -371,10 +370,8 @@ describe("ImageCard", () => { await waitFor(() => { expect(window.open).toHaveBeenCalledTimes(1); }); - - expect(window.open.mock.calls[0][0]).toContain( - "/iaResults.jsp?taskId=TASK-FR-1", - ); + const url = window.open.mock.calls[0][0]; + expect(url).toContain("/react/match-results?taskId=TASK-FR-1"); }); test("clicking MATCH_RESULTS without annotation shows alert", async () => { diff --git a/frontend/src/__tests__/pages/Encounter/ImageModal.test.js b/frontend/src/__tests__/pages/Encounter/ImageModal.test.js index b40fe8bd8d..bc3cca488e 100644 --- a/frontend/src/__tests__/pages/Encounter/ImageModal.test.js +++ b/frontend/src/__tests__/pages/Encounter/ImageModal.test.js @@ -224,8 +224,8 @@ describe("ImageModal", () => { fireEvent.click(screen.getByText("MATCH_RESULTS")); - expect(window.open).toHaveBeenCalledWith( - "/iaResults.jsp?taskId=task-123", + expect(global.open).toHaveBeenCalledWith( + "/react/match-results?taskId=task-123", "_blank", ); }); diff --git a/frontend/src/__tests__/pages/Encounter/MapDisplay.test.js b/frontend/src/__tests__/pages/Encounter/MapDisplay.test.js index a857428ea3..961f474d6e 100644 --- a/frontend/src/__tests__/pages/Encounter/MapDisplay.test.js +++ b/frontend/src/__tests__/pages/Encounter/MapDisplay.test.js @@ -1,19 +1,14 @@ import React from "react"; import { render, waitFor } from "@testing-library/react"; +import { useSiteSettings } from "../../../SiteSettingsContext"; jest.mock("mobx-react-lite", () => ({ observer: (Comp) => Comp, })); -jest.mock("../../../models/useGetSiteSettings", () => ({ +jest.mock("../../../SiteSettingsContext", () => ({ __esModule: true, - default: () => ({ - data: { - googleMapsKey: "FAKE_KEY", - mapCenterLat: 10, - mapCenterLon: 20, - }, - }), + useSiteSettings: jest.fn(), })); jest.mock("@googlemaps/js-api-loader", () => { @@ -30,6 +25,15 @@ import { MapDisplay } from "../../../pages/Encounter/MapDisplay"; describe("MapDisplay", () => { beforeEach(() => { jest.clearAllMocks(); + useSiteSettings.mockReturnValue({ + data: { + googleMapsKey: "FAKE_KEY", + mapCenterLat: 10, + mapCenterLon: 20, + }, + isLoading: false, + error: null, + }); global.window.google = { maps: { diff --git a/frontend/src/__tests__/pages/Encounter/MatchCriteria.test.js b/frontend/src/__tests__/pages/Encounter/MatchCriteria.test.js index 188053428b..35fb52e7aa 100644 --- a/frontend/src/__tests__/pages/Encounter/MatchCriteria.test.js +++ b/frontend/src/__tests__/pages/Encounter/MatchCriteria.test.js @@ -212,7 +212,7 @@ describe("MatchCriteriaModal", () => { await waitFor(() => { expect(store.newMatch.buildNewMatchPayload).toHaveBeenCalledTimes(1); expect(global.open).toHaveBeenCalledWith( - "/iaResults.jsp?taskId=t123", + "/react/match-results?taskId=t123", "_blank", ); expect(store.modals.setOpenMatchCriteriaModal).toHaveBeenCalledWith( @@ -244,18 +244,4 @@ describe("MatchCriteriaModal", () => { ); }); }); - - test("does not call handlers when siteSettingsLoading is true", async () => { - const store = makeStore({ siteSettingsLoading: true }); - - render(); - - fireEvent.click(await screen.findByTestId("tree-select")); - fireEvent.click(screen.getByTestId("react-select")); - - expect(store.newMatch.handleStrictChange).not.toHaveBeenCalled(); - expect(store.newMatch.setAlgorithm).not.toHaveBeenCalled(); - - expect(screen.getByText("MATCH").closest("button")).toBeDisabled(); - }); }); diff --git a/frontend/src/__tests__/pages/Encounter/ProjectsCard.test.js b/frontend/src/__tests__/pages/Encounter/ProjectsCard.test.js index a538ba57b9..5584df8727 100644 --- a/frontend/src/__tests__/pages/Encounter/ProjectsCard.test.js +++ b/frontend/src/__tests__/pages/Encounter/ProjectsCard.test.js @@ -93,8 +93,6 @@ describe("ProjectsCard", () => { render(); expect(screen.getByText("PROJECTS")).toBeInTheDocument(); - expect(screen.getByText("Project 1")).toBeInTheDocument(); - expect(screen.getByText("Project 2")).toBeInTheDocument(); expect(screen.getByText(/Project ID: p1/)).toBeInTheDocument(); expect(screen.getByText(/Project ID: p2/)).toBeInTheDocument(); diff --git a/frontend/src/__tests__/pages/EncounterSearchPageAndFilters/CalenderView.test.js b/frontend/src/__tests__/pages/EncounterSearchPageAndFilters/CalenderView.test.js index 09990a1a8a..c7c239e377 100644 --- a/frontend/src/__tests__/pages/EncounterSearchPageAndFilters/CalenderView.test.js +++ b/frontend/src/__tests__/pages/EncounterSearchPageAndFilters/CalenderView.test.js @@ -1,3 +1,4 @@ +/* eslint-disable react/display-name */ import React from "react"; import { render, screen, waitFor } from "@testing-library/react"; import CalendarView from "../../../pages/SearchPages/searchResultTabs/CalendarView"; diff --git a/frontend/src/__tests__/pages/LandingPage/LandingPage.test.js b/frontend/src/__tests__/pages/LandingPage/LandingPage.test.js index 28936c9bd3..d59a0eecc8 100644 --- a/frontend/src/__tests__/pages/LandingPage/LandingPage.test.js +++ b/frontend/src/__tests__/pages/LandingPage/LandingPage.test.js @@ -3,8 +3,12 @@ import { screen } from "@testing-library/react"; import Home from "../../../pages/Home"; import useGetHomePageInfo from "../../../models/useGetHomePageInfo"; import { renderWithProviders } from "../../../utils/utils"; +import { useSiteSettings } from "../../../SiteSettingsContext"; jest.mock("../../../models/useGetHomePageInfo"); +jest.mock("../../../SiteSettingsContext", () => ({ + useSiteSettings: jest.fn(), +})); jest.mock("../../../components/home/LandingImage", () => { const React = require("react"); const MockLandingImage = () => @@ -77,6 +81,14 @@ jest.mock("../../../pages/errorPages/Forbidden", () => { }); describe("Home Page", () => { + beforeEach(() => { + useSiteSettings.mockReturnValue({ + data: {}, + isLoading: false, + error: null, + }); + }); + test("renders the main components when API call is successful", () => { useGetHomePageInfo.mockReturnValue({ data: { diff --git a/frontend/src/__tests__/pages/LandingPage/PickUpWhereYouLeft.test.js b/frontend/src/__tests__/pages/LandingPage/PickUpWhereYouLeft.test.js index 13f3c0d26a..fbd02ca48b 100644 --- a/frontend/src/__tests__/pages/LandingPage/PickUpWhereYouLeft.test.js +++ b/frontend/src/__tests__/pages/LandingPage/PickUpWhereYouLeft.test.js @@ -76,7 +76,7 @@ describe("PickUp Component", () => { expect(latestMatchItem).toHaveTextContent( formatDate(mockData.latestMatchTask.dateTimeCreated, true), ); - expect(latestMatchItem).toHaveTextContent("/iaResults.jsp?taskId=123"); + expect(latestMatchItem).toHaveTextContent("/react/match-results?taskId=123"); }); test("generates the correct matchActionButtonUrl based on date", () => { diff --git a/frontend/src/__tests__/pages/ManualAnnotationPage/ManualAnnotation.test.js b/frontend/src/__tests__/pages/ManualAnnotationPage/ManualAnnotation.test.js index 0767287cb2..4de943cf1c 100644 --- a/frontend/src/__tests__/pages/ManualAnnotationPage/ManualAnnotation.test.js +++ b/frontend/src/__tests__/pages/ManualAnnotationPage/ManualAnnotation.test.js @@ -10,6 +10,7 @@ import { import { MemoryRouter } from "react-router-dom"; import { IntlProvider } from "react-intl"; import ManualAnnotation from "../../../pages/ManualAnnotation"; +import { useSiteSettings } from "../../../SiteSettingsContext"; jest.mock("mobx-react-lite", () => ({ observer: (Comp) => Comp, @@ -78,8 +79,8 @@ const siteSettingsState = { iaClassesForTaxonomy: { testTaxonomy: ["Zebra", "Elephant"] }, annotationViewpoint: ["Front", "Side"], }; -jest.mock("../../../models/useGetSiteSettings", () => () => ({ - data: siteSettingsState, +jest.mock("../../../SiteSettingsContext", () => ({ + useSiteSettings: jest.fn(), })); let latestAnnotationSuccessfulProps; @@ -249,6 +250,11 @@ describe("ManualAnnotation (important coverage)", () => { testTaxonomy: ["Zebra", "Elephant"], }; siteSettingsState.annotationViewpoint = ["Front", "Side"]; + useSiteSettings.mockReturnValue({ + data: siteSettingsState, + isLoading: false, + error: null, + }); global.alert = jest.fn(); }); diff --git a/frontend/src/__tests__/pages/MatchResults/CreateNewIndividualModal.test.jsx b/frontend/src/__tests__/pages/MatchResults/CreateNewIndividualModal.test.jsx new file mode 100644 index 0000000000..d76f060530 --- /dev/null +++ b/frontend/src/__tests__/pages/MatchResults/CreateNewIndividualModal.test.jsx @@ -0,0 +1,166 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { IntlProvider } from "react-intl"; +import CreateNewIndividualModal from "../../../pages/MatchResultsPage/components/CreateNewIndividualModal"; + +const themeColor = { + primaryColors: { primary500: "#00ACCE" }, +}; + +const messages = { + CREATE_NEW_INDIVIDUAL: "CREATE_NEW_INDIVIDUAL", + CANCEL: "CANCEL", + USE_THIS: "USE_THIS", +}; + +const defaultProps = { + show: true, + onHide: jest.fn(), + encounterId: "enc-001", + newIndividualName: "", + onNameChange: jest.fn(), + onConfirm: jest.fn(), + loading: false, + themeColor, + identificationRemarks: ["AI-assisted", "Manual review"], + locationId: "", +}; + +const renderModal = (props = {}) => + render( + + + , + ); + +beforeEach(() => { + globalThis.fetch = jest.fn(() => + Promise.resolve({ json: () => Promise.resolve({ success: false }) }), + ); +}); + +describe("CreateNewIndividualModal", () => { + test("does not render when show is false", () => { + renderModal({ show: false }); + expect(screen.queryByText("CREATE_NEW_INDIVIDUAL")).not.toBeInTheDocument(); + }); + + test("renders modal title when show is true", () => { + renderModal(); + expect(screen.getAllByText("CREATE_NEW_INDIVIDUAL")[0]).toBeInTheDocument(); + }); + + test("displays encounter ID as a link", () => { + renderModal({ encounterId: "enc-abc" }); + const link = screen.getByText("enc-abc"); + expect(link.tagName).toBe("A"); + expect(link.href).toContain("enc-abc"); + }); + + test("renders identification remarks as dropdown options", () => { + renderModal(); + expect(screen.getByText("AI-assisted")).toBeInTheDocument(); + expect(screen.getByText("Manual review")).toBeInTheDocument(); + }); + + test("confirm button is disabled when name is empty", () => { + renderModal({ newIndividualName: "" }); + const confirmBtn = screen + .getAllByText("CREATE_NEW_INDIVIDUAL") + .find((el) => el.closest("button")); + expect(confirmBtn.closest("button")).toBeDisabled(); + }); + + test("confirm button is enabled when name is provided", () => { + renderModal({ newIndividualName: "Nemo" }); + const confirmBtn = screen + .getAllByText("CREATE_NEW_INDIVIDUAL") + .find((el) => el.closest("button")); + expect(confirmBtn.closest("button")).not.toBeDisabled(); + }); + + test("confirm button is disabled while loading", () => { + renderModal({ newIndividualName: "Nemo", loading: true }); + const confirmBtn = screen + .getAllByText("CREATE_NEW_INDIVIDUAL") + .find((el) => el.closest("button")); + expect(confirmBtn.closest("button")).toBeDisabled(); + }); + + test("clicking confirm calls onConfirm with selected remark", () => { + const onConfirm = jest.fn(); + renderModal({ newIndividualName: "Nemo", onConfirm }); + const select = screen.getByRole("combobox"); + fireEvent.change(select, { target: { value: "AI-assisted" } }); + fireEvent.click( + screen + .getAllByText("CREATE_NEW_INDIVIDUAL") + .find((el) => el.closest("button")) + .closest("button"), + ); + expect(onConfirm).toHaveBeenCalledWith("AI-assisted"); + }); + + test("name input change calls onNameChange", () => { + const onNameChange = jest.fn(); + renderModal({ onNameChange }); + fireEvent.change(screen.getByPlaceholderText("Enter name"), { + target: { value: "Luna" }, + }); + expect(onNameChange).toHaveBeenCalledWith("Luna", false); + }); + + test("CANCEL button calls onHide", () => { + const onHide = jest.fn(); + renderModal({ onHide }); + fireEvent.click(screen.getByText("CANCEL")); + expect(onHide).toHaveBeenCalled(); + }); + + test("fetches suggested ID when show=true and locationId provided", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + success: true, + results: [{ success: true, nextName: "ID-007" }], + }), + }); + renderModal({ locationId: "loc-1" }); + await waitFor(() => { + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining("next_name?locationId=loc-1"), + ); + }); + }); + + test("shows suggested ID and USE_THIS button after fetch", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + success: true, + results: [{ success: true, nextName: "ID-042" }], + }), + }); + renderModal({ locationId: "loc-2" }); + expect(await screen.findByText(/ID-042/)).toBeInTheDocument(); + expect(screen.getByText("USE_THIS")).toBeInTheDocument(); + }); + + test("clicking USE_THIS calls onNameChange with suggested ID", async () => { + globalThis.fetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + success: true, + results: [{ success: true, nextName: "ID-099" }], + }), + }); + const onNameChange = jest.fn(); + renderModal({ locationId: "loc-3", onNameChange }); + await screen.findByText("USE_THIS"); + fireEvent.click(screen.getByText("USE_THIS")); + expect(onNameChange).toHaveBeenCalledWith("ID-099", true); + }); +}); diff --git a/frontend/src/__tests__/pages/MatchResults/InstructionsModal.test.jsx b/frontend/src/__tests__/pages/MatchResults/InstructionsModal.test.jsx new file mode 100644 index 0000000000..4d326f7112 --- /dev/null +++ b/frontend/src/__tests__/pages/MatchResults/InstructionsModal.test.jsx @@ -0,0 +1,82 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { IntlProvider } from "react-intl"; +import InstructionsModal from "../../../pages/MatchResultsPage/components/InstructionsModal"; + +const themeColor = { + primaryColors: { primary500: "#00ACCE" }, +}; + +const renderModal = (props = {}) => + render( + + + , + ); + +describe("InstructionsModal", () => { + test("does not render when show is false", () => { + renderModal({ show: false }); + expect( + screen.queryByText("MATCHING_PAGE_INSTRUCTIONS"), + ).not.toBeInTheDocument(); + }); + + test("renders modal title when show is true", () => { + renderModal(); + expect(screen.getByText("MATCHING_PAGE_INSTRUCTIONS")).toBeInTheDocument(); + }); + + test("displays the task ID", () => { + renderModal({ taskId: "task-xyz" }); + expect(screen.getByText("task-xyz")).toBeInTheDocument(); + }); + + test("shows dash when taskId is not provided", () => { + renderModal({ taskId: null }); + expect(screen.getByText("-")).toBeInTheDocument(); + }); + + test("copy button is disabled when taskId is null", () => { + renderModal({ taskId: null }); + const copyBtn = screen.getByRole("button", { name: /copy/i }); + expect(copyBtn).toBeDisabled(); + }); + + test("copy button is enabled when taskId is provided", () => { + renderModal({ taskId: "task-123" }); + const copyBtn = screen.getByRole("button", { name: /copy/i }); + expect(copyBtn).not.toBeDisabled(); + }); + + test("clicking copy button writes taskId to clipboard", async () => { + Object.assign(navigator, { + clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, + }); + renderModal({ taskId: "task-abc" }); + fireEvent.click(screen.getByRole("button", { name: /copy/i })); + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("task-abc"); + }); + }); + + test("renders section titles for scores, project, tools", () => { + renderModal(); + expect(screen.getByText("SCORES")).toBeInTheDocument(); + expect(screen.getByText("PROJECT")).toBeInTheDocument(); + expect(screen.getByText("TOOLS")).toBeInTheDocument(); + }); + + test("renders documentation link", () => { + renderModal(); + const link = screen.getByText("WILDBOOK_DOCUMENTATION"); + expect(link.tagName).toBe("A"); + expect(link.getAttribute("target")).toBe("_blank"); + }); +}); diff --git a/frontend/src/__tests__/pages/MatchResults/MatchConfirmedModal.test.jsx b/frontend/src/__tests__/pages/MatchResults/MatchConfirmedModal.test.jsx new file mode 100644 index 0000000000..065b4bb2d7 --- /dev/null +++ b/frontend/src/__tests__/pages/MatchResults/MatchConfirmedModal.test.jsx @@ -0,0 +1,80 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { IntlProvider } from "react-intl"; +import MatchConfirmedModal from "../../../pages/MatchResultsPage/components/MatchConfirmedModal"; + +const themeColor = { + primaryColors: { primary500: "#00ACCE" }, +}; + +const messages = { + MATCH_CONFIRMED: "MATCH_CONFIRMED", + YOU_MERGED_N_ENCOUNTERS: "You merged {count} encounters", + CLOSE: "CLOSE", +}; + +const renderModal = (props = {}) => + render( + + + , + ); + +describe("MatchConfirmedModal", () => { + test("does not render when show is false", () => { + renderModal({ show: false }); + expect(screen.queryByText("MATCH_CONFIRMED")).not.toBeInTheDocument(); + }); + + test("renders modal title when show is true", () => { + renderModal(); + expect(screen.getByText("MATCH_CONFIRMED")).toBeInTheDocument(); + }); + + test("shows encounter link and individual link when encounterCount is 0", () => { + renderModal({ encounterCount: 0, encounterId: "enc-abc" }); + expect(screen.getByText("enc-abc")).toBeInTheDocument(); + expect(screen.getByText("Luna")).toBeInTheDocument(); + }); + + test("shows merge message when encounterCount > 0", () => { + renderModal({ encounterCount: 3 }); + expect(screen.getByText(/YOU_MERGED/)).toBeInTheDocument(); + // No encounter link in merged case + expect(screen.queryByText("enc-123")).not.toBeInTheDocument(); + }); + + test("falls back to individualId when individualName is not provided", () => { + renderModal({ individualName: null, individualId: "ind-999" }); + expect(screen.getByText("ind-999")).toBeInTheDocument(); + }); + + test("CLOSE button calls onHide and reloads page", () => { + const onHide = jest.fn(); + const reload = jest.fn(); + Object.defineProperty(window, "location", { + value: { reload }, + writable: true, + }); + renderModal({ onHide }); + fireEvent.click(screen.getByText("CLOSE")); + expect(onHide).toHaveBeenCalled(); + expect(reload).toHaveBeenCalled(); + }); + + test("individual link points to individuals.jsp with correct id", () => { + renderModal({ individualId: "ind-xyz" }); + const link = screen.getByText("Luna").closest("a"); + expect(link.href).toContain("individuals.jsp"); + expect(link.href).toContain("ind-xyz"); + }); +}); diff --git a/frontend/src/__tests__/pages/MatchResults/MatchCriteriaDrawer.test.jsx b/frontend/src/__tests__/pages/MatchResults/MatchCriteriaDrawer.test.jsx new file mode 100644 index 0000000000..885d9e0387 --- /dev/null +++ b/frontend/src/__tests__/pages/MatchResults/MatchCriteriaDrawer.test.jsx @@ -0,0 +1,100 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { IntlProvider } from "react-intl"; +import MatchCriteriaDrawer from "../../../pages/MatchResultsPage/components/MatchCriteriaDrawer"; + +jest.mock("react-bootstrap", () => { + const React = require("react"); + + function Offcanvas({ show, children }) { + if (!show) return null; + return React.createElement("div", { "data-testid": "offcanvas" }, children); + } + Offcanvas.displayName = "MockOffcanvas"; + + function OffcanvasHeader({ children }) { + return React.createElement( + "div", + { "data-testid": "offcanvas-header" }, + children, + ); + } + OffcanvasHeader.displayName = "MockOffcanvasHeader"; + Offcanvas.Header = OffcanvasHeader; + + function OffcanvasTitle({ children }) { + return React.createElement( + "div", + { "data-testid": "offcanvas-title" }, + children, + ); + } + OffcanvasTitle.displayName = "MockOffcanvasTitle"; + Offcanvas.Title = OffcanvasTitle; + + function OffcanvasBody({ children, style }) { + return React.createElement( + "div", + { "data-testid": "offcanvas-body", style }, + children, + ); + } + OffcanvasBody.displayName = "MockOffcanvasBody"; + Offcanvas.Body = OffcanvasBody; + + return { Offcanvas }; +}); + +const renderDrawer = (props = {}) => + render( + + + , + ); + +describe("MatchCriteriaDrawer", () => { + test("does not render when show is false", () => { + renderDrawer({ show: false }); + expect(screen.queryByText("MATCH_CRITERIA")).not.toBeInTheDocument(); + }); + + test("renders title when show is true", () => { + renderDrawer(); + expect(screen.getByText("MATCH_CRITERIA")).toBeInTheDocument(); + }); + + test("shows FILTER_SET_FOR_TASK message", () => { + renderDrawer(); + expect(screen.getByText("FILTER_SET_FOR_TASK")).toBeInTheDocument(); + }); + + test("shows location IDs when filter has locationIds", () => { + renderDrawer({ filter: { locationIds: ["loc-1", "loc-2"] } }); + expect(screen.getByText(/loc-1, loc-2/)).toBeInTheDocument(); + }); + + test("shows owner when filter has owner", () => { + renderDrawer({ filter: { owner: "user@example.com" } }); + expect(screen.getByText(/user@example.com/)).toBeInTheDocument(); + }); + + test("shows NO_FILTER_SET_FOR_TASK when filter is empty", () => { + renderDrawer({ filter: {} }); + expect(screen.getByText("NO_FILTER_SET_FOR_TASK")).toBeInTheDocument(); + }); + + test("shows NO_FILTER_SET_FOR_TASK when filter is null", () => { + renderDrawer({ filter: null }); + expect(screen.getByText("NO_FILTER_SET_FOR_TASK")).toBeInTheDocument(); + }); + + test("does not show location section when locationIds is empty", () => { + renderDrawer({ filter: { locationIds: [] } }); + expect(screen.queryByText("LOCATION_IDS")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/pages/MatchResults/MatchResults.test.jsx b/frontend/src/__tests__/pages/MatchResults/MatchResults.test.jsx new file mode 100644 index 0000000000..fa0f6d8655 --- /dev/null +++ b/frontend/src/__tests__/pages/MatchResults/MatchResults.test.jsx @@ -0,0 +1,393 @@ +import React from "react"; +import { + render, + screen, + fireEvent, + waitFor, + act, + within, +} from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { IntlProvider } from "react-intl"; +import axios from "axios"; +import MatchResults from "../../../pages/MatchResultsPage/MatchResults"; + +jest.mock("axios"); + +jest.mock("../../../SiteSettingsContext", () => ({ + useSiteSettings: () => ({ + projectsForUser: { + "proj-1": { name: "Project Alpha", prefix: "PA" }, + "proj-2": { name: "Project Beta", prefix: "PB" }, + }, + identificationRemarks: ["Confirmed", "Uncertain"], + }), +})); + +jest.mock("../../../components/FullScreenLoader", () => { + const React = require("react"); + function FullScreenLoader() { + return React.createElement("div", { "data-testid": "full-screen-loader" }); + } + FullScreenLoader.displayName = "FullScreenLoader"; + return FullScreenLoader; +}); + +jest.mock( + "../../../pages/MatchResultsPage/components/MatchProspectTable", + () => { + const React = require("react"); + function MatchProspectTable({ taskId, columns, onToggleSelected }) { + return React.createElement( + "div", + { "data-testid": "prospect-table-" + taskId }, + (columns || []).flat().map(function (col, i) { + return React.createElement( + "button", + { + key: i, + "data-testid": "prospect-row", + onClick: function () { + if (onToggleSelected) { + onToggleSelected( + true, + "key-" + i, + "enc-" + i, + "ind-" + i, + "Name" + i, + ); + } + }, + }, + "row-" + i, + ); + }), + ); + } + MatchProspectTable.displayName = "MatchProspectTable"; + return MatchProspectTable; + }, +); + +jest.mock( + "../../../pages/MatchResultsPage/components/MatchResultsBottomBar", + () => { + const React = require("react"); + function MatchResultsBottomBar() { + return React.createElement("div", { "data-testid": "bottom-bar" }); + } + MatchResultsBottomBar.displayName = "MatchResultsBottomBar"; + return MatchResultsBottomBar; + }, +); + +jest.mock( + "../../../pages/MatchResultsPage/components/InstructionsModal", + () => { + const React = require("react"); + function InstructionsModal({ show, onHide }) { + if (!show) return null; + return React.createElement( + "div", + { "data-testid": "instructions-modal" }, + React.createElement( + "button", + { onClick: onHide, "data-testid": "close-instructions" }, + "Close", + ), + ); + } + InstructionsModal.displayName = "InstructionsModal"; + return InstructionsModal; + }, +); + +jest.mock( + "../../../pages/MatchResultsPage/components/MatchCriteriaDrawer", + () => { + const React = require("react"); + function MatchCriteriaDrawer({ show, onHide }) { + if (!show) return null; + return React.createElement( + "div", + { "data-testid": "match-criteria-drawer" }, + React.createElement( + "button", + { onClick: onHide, "data-testid": "close-drawer" }, + "Close", + ), + ); + } + MatchCriteriaDrawer.displayName = "MatchCriteriaDrawer"; + return MatchCriteriaDrawer; + }, +); + +jest.mock("../../../components/MultiSelectWithCheckbox", () => { + const React = require("react"); + function MultiSelectWithCheckbox({ + options, + value, + onChangeCommitted, + placeholder, + }) { + return React.createElement( + "select", + { + "data-testid": "project-multiselect", + value: value[0] || "", + onChange: function (e) { + onChangeCommitted([e.target.value]); + }, + }, + React.createElement("option", { value: "" }, placeholder), + (options || []).map(function (o) { + return React.createElement( + "option", + { key: o.value, value: o.value }, + o.label, + ); + }), + ); + } + MultiSelectWithCheckbox.displayName = "MultiSelectWithCheckbox"; + return MultiSelectWithCheckbox; +}); + +// --------------------------------------------------------------------------- + +const makeApiResponse = () => ({ + matchResultsRoot: { + id: "task-1", + status: "complete", + statusOverall: "complete", + dateCreated: "2024-06-01", + method: { name: "hotspotter", description: "HotSpotter" }, + matchingSetFilter: {}, + matchResults: { + numberCandidates: 10, + queryAnnotation: { + x: 0.1, + y: 0.2, + width: 0.3, + height: 0.4, + theta: 0, + asset: { url: "http://img.test/query.jpg" }, + encounter: { id: "enc-query", locationId: "loc-1" }, + individual: { id: "ind-query", displayName: "Luna" }, + }, + prospects: { + annot: [{ annotId: "a1", score: 0.9 }], + indiv: [{ individualId: "i1", score: 0.85 }], + }, + }, + children: [], + }, +}); + +const renderComponent = (url = "/match-results?taskId=task-1") => + render( + + + + + , + ); + +// --------------------------------------------------------------------------- + +describe("MatchResults component", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("shows loader while fetching", async () => { + let resolveRequest; + axios.get.mockImplementation( + () => + new Promise((resolve) => { + resolveRequest = () => resolve({ data: makeApiResponse() }); + }), + ); + renderComponent(); + expect(screen.getByTestId("full-screen-loader")).toBeInTheDocument(); + await act(async () => { + resolveRequest(); + }); + }); + + test("shows 'no match results' message when no taskId in URL", async () => { + renderComponent("/match-results"); + expect(await screen.findByText(/NO_MATCH_RESULT/i)).toBeInTheDocument(); + }); + + test("renders match prospect table after successful fetch", async () => { + axios.get.mockResolvedValueOnce({ data: makeApiResponse() }); + renderComponent(); + expect( + await screen.findByTestId("prospect-table-task-1"), + ).toBeInTheDocument(); + }); + + test("renders bottom bar when results are available", async () => { + axios.get.mockResolvedValueOnce({ data: makeApiResponse() }); + renderComponent(); + expect(await screen.findByTestId("bottom-bar")).toBeInTheDocument(); + }); + + test("renders INDIVIDUAL_SCORE and IMAGE_SCORE view mode buttons", async () => { + axios.get.mockResolvedValueOnce({ data: makeApiResponse() }); + renderComponent(); + await screen.findByTestId("prospect-table-task-1"); + expect(screen.getByText("INDIVIDUAL_SCORE")).toBeInTheDocument(); + expect(screen.getByText("IMAGE_SCORE")).toBeInTheDocument(); + }); + + test("clicking IMAGE_SCORE button does not crash", async () => { + axios.get.mockResolvedValue({ data: makeApiResponse() }); + renderComponent(); + await screen.findByTestId("prospect-table-task-1"); + await act(async () => { + fireEvent.click(screen.getByText("IMAGE_SCORE")); + }); + expect(screen.getByText("IMAGE_SCORE")).toBeInTheDocument(); + }); + + test("InfoIcon click opens InstructionsModal", async () => { + axios.get.mockResolvedValueOnce({ data: makeApiResponse() }); + renderComponent(); + await screen.findByTestId("prospect-table-task-1"); + expect(screen.queryByTestId("instructions-modal")).not.toBeInTheDocument(); + const infoWrapper = screen.getByTitle("Match Page Instructions"); + fireEvent.click(within(infoWrapper).getByRole("button")); + expect(screen.getByTestId("instructions-modal")).toBeInTheDocument(); + }); + + test("Closing InstructionsModal hides it", async () => { + axios.get.mockResolvedValueOnce({ data: makeApiResponse() }); + renderComponent(); + await screen.findByTestId("prospect-table-task-1"); + const infoWrapper = screen.getByTitle("Match Page Instructions"); + fireEvent.click(within(infoWrapper).getByRole("button")); + fireEvent.click(screen.getByTestId("close-instructions")); + expect(screen.queryByTestId("instructions-modal")).not.toBeInTheDocument(); + }); + + test("FilterIcon click opens MatchCriteriaDrawer", async () => { + axios.get.mockResolvedValueOnce({ data: makeApiResponse() }); + renderComponent(); + await screen.findByTestId("prospect-table-task-1"); + expect( + screen.queryByTestId("match-criteria-drawer"), + ).not.toBeInTheDocument(); + fireEvent.click(screen.getByTitle("Match Criteria")); + expect(screen.getByTestId("match-criteria-drawer")).toBeInTheDocument(); + }); + + test("Closing MatchCriteriaDrawer hides it", async () => { + axios.get.mockResolvedValueOnce({ data: makeApiResponse() }); + renderComponent(); + await screen.findByTestId("prospect-table-task-1"); + fireEvent.click(screen.getByTitle("Match Criteria")); + fireEvent.click(screen.getByTestId("close-drawer")); + expect( + screen.queryByTestId("match-criteria-drawer"), + ).not.toBeInTheDocument(); + }); + + test("NUMBER_OF_RESULTS label is rendered", async () => { + axios.get.mockResolvedValueOnce({ data: makeApiResponse() }); + renderComponent(); + await screen.findByTestId("prospect-table-task-1"); + expect(screen.getByText("NUMBER_OF_RESULTS")).toBeInTheDocument(); + }); + + test("numResults input accepts numeric value", async () => { + axios.get.mockResolvedValue({ data: makeApiResponse() }); + renderComponent(); + await screen.findByTestId("prospect-table-task-1"); + const input = screen.getByDisplayValue("12"); + fireEvent.change(input, { target: { value: "20" } }); + expect(input.value).toBe("20"); + }); + + test("non-numeric input is rejected in numResults field", async () => { + axios.get.mockResolvedValue({ data: makeApiResponse() }); + renderComponent(); + await screen.findByTestId("prospect-table-task-1"); + const input = screen.getByDisplayValue("12"); + fireEvent.change(input, { target: { value: "abc" } }); + expect(input.value).toBe("12"); + }); + + test("pressing Enter on numResults input triggers fetch", async () => { + axios.get.mockResolvedValue({ data: makeApiResponse() }); + renderComponent(); + await screen.findByTestId("prospect-table-task-1"); + const input = screen.getByDisplayValue("12"); + fireEvent.keyDown(input, { key: "Enter" }); + await waitFor(() => expect(axios.get).toHaveBeenCalled()); + }); + + test("focus on numResults input shows confirm checkmark button", async () => { + axios.get.mockResolvedValue({ data: makeApiResponse() }); + renderComponent(); + await screen.findByTestId("prospect-table-task-1"); + fireEvent.focus(screen.getByDisplayValue("12")); + expect(screen.getByTitle("Apply changes")).toBeInTheDocument(); + }); + + test("blur on numResults input hides confirm checkmark button", async () => { + axios.get.mockResolvedValue({ data: makeApiResponse() }); + renderComponent(); + await screen.findByTestId("prospect-table-task-1"); + const input = screen.getByDisplayValue("12"); + fireEvent.focus(input); + fireEvent.blur(input); + expect(screen.queryByTitle("Apply changes")).not.toBeInTheDocument(); + }); + + test("renders PROJECT label and MultiSelectWithCheckbox", async () => { + axios.get.mockResolvedValueOnce({ data: makeApiResponse() }); + renderComponent(); + await screen.findByTestId("prospect-table-task-1"); + expect(screen.getByTestId("project-multiselect")).toBeInTheDocument(); + }); + + test("selecting a project triggers re-fetch", async () => { + axios.get.mockResolvedValue({ data: makeApiResponse() }); + renderComponent(); + await screen.findByTestId("prospect-table-task-1"); + await act(async () => { + fireEvent.change(screen.getByTestId("project-multiselect"), { + target: { value: "proj-1" }, + }); + }); + await waitFor(() => expect(axios.get).toHaveBeenCalled()); + }); + + test("shows 'no match results' message when API returns empty prospects", async () => { + axios.get.mockResolvedValueOnce({ + data: { + matchResultsRoot: { + id: "task-1", + method: { name: "hs" }, + matchResults: { + numberCandidates: 0, + queryAnnotation: {}, + prospects: { annot: [], indiv: [] }, + }, + children: [], + }, + }, + }); + renderComponent(); + expect(await screen.findByText(/NO_MATCH_RESULT/i)).toBeInTheDocument(); + }); + + test("does not crash when API call fails", async () => { + axios.get.mockRejectedValueOnce(new Error("network error")); + renderComponent(); + expect(await screen.findByText(/NO_MATCH_RESULT/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/pages/MatchResults/MatchResultsBottomBar.test.jsx b/frontend/src/__tests__/pages/MatchResults/MatchResultsBottomBar.test.jsx new file mode 100644 index 0000000000..d348724426 --- /dev/null +++ b/frontend/src/__tests__/pages/MatchResults/MatchResultsBottomBar.test.jsx @@ -0,0 +1,280 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { IntlProvider } from "react-intl"; +import MatchResultsBottomBar from "../../../pages/MatchResultsPage/components/MatchResultsBottomBar"; + +jest.mock("../../../components/MainButton", () => { + const React = require("react"); + function MainButton({ children, onClick, disabled }) { + return React.createElement("button", { onClick, disabled }, children); + } + MainButton.displayName = "MainButton"; + return MainButton; +}); + +jest.mock( + "../../../pages/MatchResultsPage/components/CreateNewIndividualModal", + () => { + const React = require("react"); + function CreateNewIndividualModal({ show, onHide, onConfirm }) { + if (!show) return null; + return React.createElement( + "div", + { "data-testid": "create-individual-modal" }, + React.createElement( + "button", + { onClick: () => onConfirm(""), "data-testid": "modal-confirm" }, + "Confirm", + ), + React.createElement( + "button", + { onClick: onHide, "data-testid": "modal-cancel" }, + "Cancel", + ), + ); + } + CreateNewIndividualModal.displayName = "CreateNewIndividualModal"; + return CreateNewIndividualModal; + }, +); + +jest.mock( + "../../../pages/MatchResultsPage/components/NewIndividualCreatedModal", + () => { + const React = require("react"); + function NewIndividualCreatedModal({ show, onHide }) { + if (!show) return null; + return React.createElement( + "div", + { "data-testid": "new-individual-created-modal" }, + React.createElement("button", { onClick: onHide }, "Close"), + ); + } + NewIndividualCreatedModal.displayName = "NewIndividualCreatedModal"; + return NewIndividualCreatedModal; + }, +); + +jest.mock( + "../../../pages/MatchResultsPage/components/MatchConfirmedModal", + () => { + const React = require("react"); + function MatchConfirmedModal({ show, onHide }) { + if (!show) return null; + return React.createElement( + "div", + { "data-testid": "match-confirmed-modal" }, + React.createElement("button", { onClick: onHide }, "Close"), + ); + } + MatchConfirmedModal.displayName = "MatchConfirmedModal"; + return MatchConfirmedModal; + }, +); + +// --------------------------------------------------------------------------- + +const themeColor = { + primaryColors: { + primary50: "#E5F6FF", + primary500: "#00ACCE", + primary700: "#007599", + }, +}; + +const makeStore = (overrides = {}) => ({ + matchingState: "no_individuals", + encounterId: "enc-001", + encounterLocationId: "loc-1", + individualId: null, + individualDisplayName: null, + newIndividualName: "", + matchRequestLoading: false, + matchRequestError: null, + selectedMatch: [], + selectedIncludingQuery: [{ encounterId: "enc-001", individualId: null }], + setNewIndividualName: jest.fn(), + handleCreateNewIndividual: jest.fn().mockResolvedValue({ ok: true }), + handleMatch: jest.fn().mockResolvedValue({ success: true }), + handleMerge: jest.fn().mockResolvedValue({ ok: true }), + ...overrides, +}); + +const renderBar = (storeOverrides = {}) => + render( + + + , + ); + +// --------------------------------------------------------------------------- + +describe("MatchResultsBottomBar — no_individuals state", () => { + test("renders MARK_AS_NEW_INDIVIDUAL button", () => { + renderBar({ matchingState: "no_individuals" }); + expect(screen.getByText("MARK_AS_NEW_INDIVIDUAL")).toBeInTheDocument(); + }); + + test("shows SET_MATCH_FOR message", () => { + renderBar({ matchingState: "no_individuals" }); + expect(document.body).toHaveTextContent("SET_MATCH_FOR"); + }); + + test("shows individual display name link when available", () => { + renderBar({ + matchingState: "no_individuals", + individualDisplayName: "Luna", + individualId: "ind-1", + }); + expect(screen.getByText("Luna")).toBeInTheDocument(); + }); + + test("clicking MARK_AS_NEW_INDIVIDUAL opens CreateNewIndividualModal", () => { + renderBar({ matchingState: "no_individuals" }); + fireEvent.click(screen.getByText("MARK_AS_NEW_INDIVIDUAL")); + expect(screen.getByTestId("create-individual-modal")).toBeInTheDocument(); + }); + + test("cancelling modal hides it", () => { + renderBar({ matchingState: "no_individuals" }); + fireEvent.click(screen.getByText("MARK_AS_NEW_INDIVIDUAL")); + fireEvent.click(screen.getByTestId("modal-cancel")); + expect( + screen.queryByTestId("create-individual-modal"), + ).not.toBeInTheDocument(); + }); + + test("successful confirm shows NewIndividualCreatedModal", async () => { + renderBar({ matchingState: "no_individuals" }); + fireEvent.click(screen.getByText("MARK_AS_NEW_INDIVIDUAL")); + fireEvent.click(screen.getByTestId("modal-confirm")); + expect( + await screen.findByTestId("new-individual-created-modal"), + ).toBeInTheDocument(); + }); +}); + +describe("MatchResultsBottomBar — single_individual state", () => { + const singleStore = { + matchingState: "single_individual", + encounterId: "enc-001", + individualId: "ind-001", + individualDisplayName: "Willy", + selectedIncludingQuery: [ + { + encounterId: "enc-001", + individualId: "ind-001", + individualDisplayName: "Willy", + }, + ], + }; + + test("renders CONFIRM_MATCH button", () => { + renderBar(singleStore); + expect(screen.getByText("CONFIRM_MATCH")).toBeInTheDocument(); + }); + + test("renders MERGE_INDIVIDUAL message", () => { + renderBar(singleStore); + expect(screen.getByText("MERGE_INDIVIDUAL")).toBeInTheDocument(); + }); + + test("clicking CONFIRM_MATCH calls store.handleMatch", async () => { + const handleMatch = jest.fn().mockResolvedValue({ data: {} }); + renderBar({ ...singleStore, handleMatch }); + fireEvent.click(screen.getByText("CONFIRM_MATCH")); + await waitFor(() => expect(handleMatch).toHaveBeenCalled()); + }); + + test("shows MatchConfirmedModal after successful match", async () => { + renderBar(singleStore); + fireEvent.click(screen.getByText("CONFIRM_MATCH")); + expect( + await screen.findByTestId("match-confirmed-modal"), + ).toBeInTheDocument(); + }); +}); + +describe("MatchResultsBottomBar — two_individuals state", () => { + const twoStore = { + matchingState: "two_individuals", + encounterId: "enc-001", + individualId: "ind-001", + individualDisplayName: "Willy", + selectedIncludingQuery: [ + { + encounterId: "enc-001", + individualId: "ind-001", + individualDisplayName: "Willy", + }, + { + encounterId: "enc-002", + individualId: "ind-002", + individualDisplayName: "Nemo", + }, + ], + handleMerge: jest.fn().mockResolvedValue({ ok: true }), + }; + + test("renders MERGE_INDIVIDUALS button", () => { + renderBar(twoStore); + expect(screen.getByText("MERGE_INDIVIDUALS")).toBeInTheDocument(); + }); + + test("renders MERGE message with individual names", () => { + renderBar(twoStore); + expect(document.body).toHaveTextContent("MERGE"); + expect(screen.getByText("Willy")).toBeInTheDocument(); + }); +}); + +describe("MatchResultsBottomBar — too_many_individuals state", () => { + test("shows CANNOT_MERGE_MORE_THAN_TWO alert", () => { + renderBar({ matchingState: "too_many_individuals" }); + expect(screen.getByText("CANNOT_MERGE_MORE_THAN_TWO")).toBeInTheDocument(); + }); + + test("does not render action buttons", () => { + renderBar({ matchingState: "too_many_individuals" }); + expect(screen.queryByText("CONFIRM_MATCH")).not.toBeInTheDocument(); + expect(screen.queryByText("MERGE_INDIVIDUALS")).not.toBeInTheDocument(); + }); +}); + +describe("MatchResultsBottomBar — no_further_action_needed state", () => { + test("shows NO_FURTHER_ACTION_NEEDED when a match is selected", () => { + renderBar({ + matchingState: "no_further_action_needed", + selectedMatch: [ + { key: "k1", encounterId: "enc-2", individualId: "ind-1" }, + ], + }); + expect(screen.getByText("NO_FURTHER_ACTION_NEEDED")).toBeInTheDocument(); + }); + + test("shows SET_MATCH_FOR when no match is selected", () => { + renderBar({ + matchingState: "no_further_action_needed", + selectedMatch: [], + }); + expect(screen.getByText("SET_MATCH_FOR")).toBeInTheDocument(); + }); +}); + +describe("MatchResultsBottomBar — Cancel button", () => { + test("renders CANCEL button", () => { + renderBar(); + expect(screen.getByText("CANCEL")).toBeInTheDocument(); + }); + + test("CANCEL button calls window.close", () => { + window.close = jest.fn(); + renderBar(); + fireEvent.click(screen.getByText("CANCEL")); + expect(window.close).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/__tests__/pages/MatchResults/NewIndividualCreatedModal.test.jsx b/frontend/src/__tests__/pages/MatchResults/NewIndividualCreatedModal.test.jsx new file mode 100644 index 0000000000..35976aa9ab --- /dev/null +++ b/frontend/src/__tests__/pages/MatchResults/NewIndividualCreatedModal.test.jsx @@ -0,0 +1,62 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { IntlProvider } from "react-intl"; +import NewIndividualCreatedModal from "../../../pages/MatchResultsPage/components/NewIndividualCreatedModal"; + +const themeColor = { + primaryColors: { primary500: "#00ACCE" }, +}; + +const messages = { + NEW_INDIVIDUAL_CREATED: "NEW_INDIVIDUAL_CREATED", + ASSIGNED_ENCOUNTER_AS_NEW_INDIVIDUAL: "ASSIGNED_ENCOUNTER_AS_NEW_INDIVIDUAL", + AS_NEW_INDIVIDUAL: "AS_NEW_INDIVIDUAL", + CLOSE: "CLOSE", +}; + +const renderModal = (props = {}) => + render( + + + , + ); + +describe("NewIndividualCreatedModal", () => { + test("does not render when show is false", () => { + renderModal({ show: false }); + expect( + screen.queryByText("NEW_INDIVIDUAL_CREATED"), + ).not.toBeInTheDocument(); + }); + + test("renders modal title when show is true", () => { + renderModal(); + expect(screen.getByText("NEW_INDIVIDUAL_CREATED")).toBeInTheDocument(); + }); + + test("displays the encounter ID as a link", () => { + renderModal({ encounterId: "enc-abc" }); + const link = screen.getByText("enc-abc"); + expect(link.tagName).toBe("A"); + expect(link.href).toContain("enc-abc"); + }); + + test("displays the individual name", () => { + renderModal({ individualName: "Willy" }); + expect(screen.getByText(/Willy/)).toBeInTheDocument(); + }); + + test("Close button calls onHide", () => { + const onHide = jest.fn(); + renderModal({ onHide }); + fireEvent.click(screen.getByText("CLOSE")); + expect(onHide).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/__tests__/pages/MatchResults/helperFunctions.test.js b/frontend/src/__tests__/pages/MatchResults/helperFunctions.test.js new file mode 100644 index 0000000000..29ede85813 --- /dev/null +++ b/frontend/src/__tests__/pages/MatchResults/helperFunctions.test.js @@ -0,0 +1,253 @@ +import { + getAllAnnot, + getAllIndiv, +} from "../../../pages/MatchResultsPage/helperFunctions"; + +describe("helperFunctions", () => { + const makeNode = (overrides = {}) => ({ + id: "task-1", + status: "complete", + statusOverall: "complete", + dateCreated: "2024-01-01", + method: { name: "hotspotter", description: "HotSpotter algorithm" }, + matchingSetFilter: { species: "whale" }, + matchResults: { + numberCandidates: 5, + queryAnnotation: { + x: 0.1, + y: 0.2, + width: 0.3, + height: 0.4, + theta: 0, + asset: { url: "http://example.com/img.jpg" }, + encounter: { id: "enc-1", locationId: "loc-1" }, + individual: { id: "ind-1", displayName: "Willy" }, + }, + prospects: { + annot: [{ annotId: "a1", score: 0.9 }], + indiv: [{ individualId: "i1", score: 0.85 }], + }, + }, + children: [], + ...overrides, + }); + + describe("getAllAnnot", () => { + test("returns empty array for null node", () => { + expect(getAllAnnot(null)).toEqual([]); + }); + + test("returns empty array for undefined node", () => { + expect(getAllAnnot(undefined)).toEqual([]); + }); + + test("returns common object when annot prospects is empty but task is running", () => { + const node = makeNode({ + matchResults: { + ...makeNode().matchResults, + prospects: { annot: [], indiv: [{ individualId: "i1", score: 0.9 }] }, + }, + }); + // statusOverall is "complete" (not "completed"), so task is considered still running + const result = getAllAnnot(node); + expect(result).toHaveLength(1); + expect(result[0].hasResults).toBe(false); + expect(result[0].taskId).toBe("task-1"); + }); + + test("returns common object when annot prospects is missing but task is running", () => { + const node = makeNode({ + matchResults: { + ...makeNode().matchResults, + prospects: { indiv: [{ individualId: "i1" }] }, + }, + }); + // statusOverall is "complete" (not "completed"), so task is considered still running + const result = getAllAnnot(node); + expect(result).toHaveLength(1); + expect(result[0].hasResults).toBe(false); + expect(result[0].taskId).toBe("task-1"); + }); + + test("collects annot prospects and attaches common fields", () => { + const node = makeNode(); + const result = getAllAnnot(node); + expect(result).toHaveLength(1); + const item = result[0]; + expect(item.annotId).toBe("a1"); + expect(item.score).toBe(0.9); + expect(item.algorithm).toBe("hotspotter"); + expect(item.methodDescription).toBe("HotSpotter algorithm"); + expect(item.taskId).toBe("task-1"); + expect(item.taskStatus).toBe("complete"); + expect(item.taskStatusOverall).toBe("complete"); + expect(item.date).toBe("2024-01-01"); + expect(item.numberCandidates).toBe(5); + expect(item.queryEncounterId).toBe("enc-1"); + expect(item.encounterLocationId).toBe("loc-1"); + expect(item.queryIndividualId).toBe("ind-1"); + expect(item.queryIndividualDisplayName).toBe("Willy"); + expect(item.queryEncounterImageUrl).toBe("http://example.com/img.jpg"); + expect(item.matchingSetFilter).toEqual({ species: "whale" }); + expect(item.hasResults).toBe(true); + }); + + test("attaches queryEncounterAnnotation with correct shape", () => { + const node = makeNode(); + const result = getAllAnnot(node); + expect(result[0].queryEncounterAnnotation).toEqual({ + x: 0.1, + y: 0.2, + width: 0.3, + height: 0.4, + theta: 0, + }); + }); + + test("uses method.description as methodName when method.name is undefined", () => { + const node = makeNode({ method: { description: "FlukeMatcher" } }); + const result = getAllAnnot(node); + expect(result[0].algorithm).toBe("FlukeMatcher"); + expect(result[0].methodName).toBe("FlukeMatcher"); + }); + + test("recurses into children", () => { + const child = makeNode({ + id: "task-2", + matchResults: { + ...makeNode().matchResults, + prospects: { + annot: [{ annotId: "a2", score: 0.7 }], + indiv: [], + }, + }, + }); + const node = makeNode({ children: [child] }); + const result = getAllAnnot(node); + expect(result).toHaveLength(2); + expect(result.map((r) => r.annotId)).toContain("a2"); + }); + + test("returns common object when no matchResults but task is running", () => { + const node = makeNode({ matchResults: null }); + // statusOverall is "complete" (not "completed"), so task is considered still running + const result = getAllAnnot(node); + expect(result).toHaveLength(1); + expect(result[0].hasResults).toBe(false); + expect(result[0].taskId).toBe("task-1"); + }); + + test("handles multiple annot prospects in one node", () => { + const node = makeNode({ + matchResults: { + ...makeNode().matchResults, + prospects: { + annot: [ + { annotId: "a1", score: 0.9 }, + { annotId: "a2", score: 0.8 }, + { annotId: "a3", score: 0.7 }, + ], + indiv: [], + }, + }, + }); + expect(getAllAnnot(node)).toHaveLength(3); + }); + + test("sets displayIndex correctly via spread (items keep own keys)", () => { + const node = makeNode(); + const result = getAllAnnot(node); + // common fields should override item fields if same key + expect(result[0].hasResults).toBe(true); + }); + }); + + describe("getAllIndiv", () => { + test("returns empty array for null node", () => { + expect(getAllIndiv(null)).toEqual([]); + }); + + test("returns common object when indiv prospects is empty but task is running", () => { + const node = makeNode({ + matchResults: { + ...makeNode().matchResults, + prospects: { annot: [{ annotId: "a1" }], indiv: [] }, + }, + }); + // statusOverall is "complete" (not "completed"), so task is considered still running + const result = getAllIndiv(node); + expect(result).toHaveLength(1); + expect(result[0].hasResults).toBe(false); + expect(result[0].taskId).toBe("task-1"); + }); + + test("collects indiv prospects and attaches common fields", () => { + const node = makeNode(); + const result = getAllIndiv(node); + expect(result).toHaveLength(1); + const item = result[0]; + expect(item.individualId).toBe("i1"); + expect(item.score).toBe(0.85); + expect(item.algorithm).toBe("hotspotter"); + expect(item.taskId).toBe("task-1"); + expect(item.hasResults).toBe(true); + }); + + test("recurses into multiple levels of children", () => { + const grandchild = makeNode({ + id: "task-3", + matchResults: { + ...makeNode().matchResults, + prospects: { + annot: [], + indiv: [{ individualId: "i3", score: 0.6 }], + }, + }, + }); + const child = makeNode({ + id: "task-2", + children: [grandchild], + matchResults: { + ...makeNode().matchResults, + prospects: { + annot: [], + indiv: [{ individualId: "i2", score: 0.7 }], + }, + }, + }); + const root = makeNode({ children: [child] }); + const result = getAllIndiv(root); + expect(result).toHaveLength(3); + expect(result.map((r) => r.individualId)).toEqual( + expect.arrayContaining(["i1", "i2", "i3"]), + ); + }); + + test("numberCandidates defaults to 0 when missing", () => { + const node = makeNode({ + matchResults: { + queryAnnotation: makeNode().matchResults.queryAnnotation, + prospects: { annot: [], indiv: [{ individualId: "i1" }] }, + }, + }); + expect(getAllIndiv(node)[0].numberCandidates).toBe("-"); + }); + + test("queryEncounterId is null when encounter is absent", () => { + const node = makeNode({ + matchResults: { + numberCandidates: 3, + queryAnnotation: { asset: null }, + prospects: { + annot: [], + indiv: [{ individualId: "i1" }], + }, + }, + }); + const result = getAllIndiv(node); + expect(result[0].queryEncounterId).toBeNull(); + expect(result[0].queryIndividualId).toBeNull(); + expect(result[0].queryEncounterImageUrl).toBeNull(); + }); + }); +}); diff --git a/frontend/src/__tests__/pages/MatchResults/matchResultsStore.test.js b/frontend/src/__tests__/pages/MatchResults/matchResultsStore.test.js new file mode 100644 index 0000000000..a50a19a38c --- /dev/null +++ b/frontend/src/__tests__/pages/MatchResults/matchResultsStore.test.js @@ -0,0 +1,852 @@ +import MatchResultsStore from "../../../pages/MatchResultsPage/stores/matchResultsStore"; +import axios from "axios"; + +jest.mock("axios"); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const makeProspect = (overrides = {}) => ({ + id: "task-1", + status: "complete", + statusOverall: "complete", + dateCreated: "2024-06-01", + method: { name: "hotspotter", description: "HotSpotter" }, + matchingSetFilter: {}, + matchResults: { + numberCandidates: 10, + queryAnnotation: { + x: 0.1, + y: 0.2, + width: 0.3, + height: 0.4, + theta: 0, + asset: { url: "http://img.test/query.jpg" }, + encounter: { id: "enc-query", locationId: "loc-1" }, + individual: { id: "ind-query", displayName: "Luna" }, + }, + prospects: { + annot: [{ annotId: "a1", score: 0.9 }], + indiv: [{ individualId: "i1", score: 0.85 }], + }, + }, + children: [], + ...overrides, +}); + +const makeApiResponse = (nodeOverrides = {}) => ({ + matchResultsRoot: makeProspect(nodeOverrides), +}); + +// --------------------------------------------------------------------------- + +describe("MatchResultsStore — initial state", () => { + let store; + beforeEach(() => { + store = new MatchResultsStore(); + }); + + test("viewMode defaults to 'individual'", () => { + expect(store.viewMode).toBe("individual"); + }); + + test("numResults defaults to 12", () => { + expect(store.numResults).toBe(12); + }); + + test("projectNames defaults to empty array", () => { + expect(store.projectNames).toEqual([]); + }); + + test("selectedMatch defaults to empty array", () => { + expect(store.selectedMatch).toEqual([]); + }); + + test("loading defaults to false", () => { + expect(store.loading).toBe(false); + }); + + test("hasResults defaults to false", () => { + expect(store.hasResults).toBe(false); + }); + + test("taskId defaults to null", () => { + expect(store.taskId).toBeNull(); + }); + + test("matchRequestError defaults to null", () => { + expect(store.matchRequestError).toBeNull(); + }); + + test("newIndividualName defaults to empty string", () => { + expect(store.newIndividualName).toBe(""); + }); +}); + +// --------------------------------------------------------------------------- + +describe("MatchResultsStore — setters", () => { + let store; + beforeEach(() => { + store = new MatchResultsStore(); + }); + + test("setViewMode updates viewMode", () => { + store.setViewMode("image"); + expect(store.viewMode).toBe("image"); + store.setViewMode("individual"); + expect(store.viewMode).toBe("individual"); + }); + + test("setTaskId updates taskId", () => { + store.setTaskId("abc-123"); + expect(store.taskId).toBe("abc-123"); + }); + + test("setNumResults updates numResults", () => { + store.setNumResults(25); + expect(store.numResults).toBe(25); + }); + + test("setLoading toggles loading flag", () => { + store.setLoading(true); + expect(store.loading).toBe(true); + store.setLoading(false); + expect(store.loading).toBe(false); + }); + + test("setHasResults updates hasResults", () => { + store.setHasResults(true); + expect(store.hasResults).toBe(true); + store.setHasResults(false); + expect(store.hasResults).toBe(false); + }); + + test("setNewIndividualName updates newIndividualName", () => { + store.setNewIndividualName("Nemo"); + expect(store.newIndividualName).toBe("Nemo"); + }); + + test("setProjectNames updates projectNames and triggers fetch when taskId set", async () => { + store.setTaskId("t-1"); + axios.get.mockResolvedValueOnce({ data: makeApiResponse() }); + store.setProjectNames(["proj-1", "proj-2"]); + expect(store.projectNames).toEqual(["proj-1", "proj-2"]); + }); + + test("setProjectNames normalises non-array to empty array", () => { + store.setProjectNames(null, { fetch: false }); + expect(store.projectNames).toEqual([]); + }); + + test("setProjectNames is no-op when value unchanged", () => { + store._projectNames = ["p1"]; + store.setTaskId("t-1"); + axios.get.mockClear(); + store.setProjectNames(["p1"]); + // value unchanged — fetch should not be triggered + expect(axios.get).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- + +describe("MatchResultsStore — loadData", () => { + let store; + beforeEach(() => { + store = new MatchResultsStore(); + }); + + test("returns task info with hasResults=false when prospects are empty but task is terminal", () => { + store.loadData({ + matchResultsRoot: makeProspect({ + statusOverall: "completed", // Terminal status + matchResults: { + numberCandidates: 0, + queryAnnotation: {}, + prospects: { annot: [], indiv: [] }, + }, + }), + }); + // Even with empty prospects, we get task info to show "no results" in UI + expect(store.hasResults).toBe(true); + expect(store._rawAnnots.length).toBe(1); + expect(store._rawIndivs.length).toBe(1); + // But the items should have hasResults: false + expect(store._rawAnnots[0].hasResults).toBe(false); + expect(store._rawIndivs[0].hasResults).toBe(false); + }); + + test("clears state when matchResultsRoot is null", () => { + store.loadData({ matchResultsRoot: null }); + expect(store.hasResults).toBe(false); + }); + + test("sets hasResults to true and populates raw arrays on valid data", () => { + store.loadData(makeApiResponse()); + expect(store.hasResults).toBe(true); + expect(store._rawIndivs.length).toBeGreaterThan(0); + expect(store._rawAnnots.length).toBeGreaterThan(0); + }); + + test("sets encounterId from queryAnnotation.encounter.id", () => { + store.loadData(makeApiResponse()); + expect(store.encounterId).toBe("enc-query"); + }); + + test("sets individualId from queryAnnotation.individual.id", () => { + store.loadData(makeApiResponse()); + expect(store.individualId).toBe("ind-query"); + }); + + test("sets individualDisplayName", () => { + store.loadData(makeApiResponse()); + expect(store.individualDisplayName).toBe("Luna"); + }); + + test("sets queryImageUrl in processed data metadata", () => { + store.loadData(makeApiResponse()); + const sections = store.processedIndivs; + expect(sections.length).toBeGreaterThan(0); + expect(sections[0].metadata.queryImageUrl).toBe( + "http://img.test/query.jpg", + ); + }); + + test("clears selectedMatch after loading (resetSelectionToQuery called)", () => { + store._selectedMatch = [{ key: "k1", encounterId: "e1" }]; + store.loadData(makeApiResponse()); + expect(store.selectedMatch).toEqual([]); + }); + + test("uses annot first item when viewMode is image", () => { + store.setViewMode("image"); + store.loadData(makeApiResponse()); + expect(store.hasResults).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- + +describe("MatchResultsStore — _processData", () => { + let store; + beforeEach(() => { + store = new MatchResultsStore(); + }); + + test("returns empty array for empty input", () => { + expect(store._processData([])).toEqual([]); + }); + + test("groups items by taskId into sections", () => { + const items = [ + { + taskId: "t1", + score: 0.9, + numberCandidates: 5, + date: "2024-01-01", + methodName: "hs", + methodDescription: "HotSpotter", + queryEncounterImageAsset: null, + queryEncounterImageUrl: null, + queryEncounterAnnotation: {}, + taskStatus: "done", + taskStatusOverall: "done", + algorithm: "hs", + }, + { + taskId: "t1", + score: 0.8, + numberCandidates: 5, + date: "2024-01-01", + methodName: "hs", + methodDescription: "HotSpotter", + queryEncounterImageAsset: null, + queryEncounterImageUrl: null, + queryEncounterAnnotation: {}, + taskStatus: "done", + taskStatusOverall: "done", + algorithm: "hs", + }, + { + taskId: "t2", + score: 0.7, + numberCandidates: 3, + date: "2024-01-02", + methodName: "fin", + methodDescription: "Finprint", + queryEncounterImageAsset: null, + queryEncounterImageUrl: null, + queryEncounterAnnotation: {}, + taskStatus: "done", + taskStatusOverall: "done", + algorithm: "fin", + }, + ]; + const sections = store._processData(items); + expect(sections).toHaveLength(2); + const t1 = sections.find((s) => s.taskId === "t1"); + expect(t1).toBeDefined(); + expect(t1.columns.flat()).toHaveLength(2); + }); + + test("splits items into columns of MAX_ROWS_PER_COLUMN (4)", () => { + const items = Array.from({ length: 9 }, (_, i) => ({ + taskId: "t1", + score: i * 0.1, + numberCandidates: 1, + date: "2024-01-01", + methodName: "m", + methodDescription: "d", + queryEncounterImageAsset: null, + queryEncounterImageUrl: null, + queryEncounterAnnotation: {}, + taskStatus: "done", + taskStatusOverall: "done", + algorithm: "m", + })); + const sections = store._processData(items); + expect(sections).toHaveLength(1); + const { columns } = sections[0]; + // 9 items → ceil(9/4) = 3 columns + expect(columns).toHaveLength(3); + expect(columns[0]).toHaveLength(4); + expect(columns[1]).toHaveLength(4); + expect(columns[2]).toHaveLength(1); + }); + + test("attaches displayIndex starting from 1", () => { + const items = Array.from({ length: 3 }, (_, i) => ({ + taskId: "t1", + score: i, + numberCandidates: 0, + date: "d", + methodName: "m", + methodDescription: "d", + queryEncounterImageAsset: null, + queryEncounterImageUrl: null, + queryEncounterAnnotation: {}, + taskStatus: null, + taskStatusOverall: null, + algorithm: "m", + })); + const sections = store._processData(items); + const flat = sections[0].columns.flat(); + expect(flat.map((f) => f.displayIndex)).toEqual([1, 2, 3]); + }); + + test("section metadata picks values from first item", () => { + const items = [ + { + taskId: "t1", + numberCandidates: 42, + date: "2024-07-04", + methodName: "mymeth", + methodDescription: "desc", + queryEncounterImageAsset: { url: "http://asset.test/img.jpg" }, + queryEncounterImageUrl: "http://asset.test/img.jpg", + queryEncounterAnnotation: { x: 1 }, + taskStatus: "running", + taskStatusOverall: "running", + algorithm: "mymeth", + }, + ]; + const sections = store._processData(items); + const meta = sections[0].metadata; + expect(meta.numCandidates).toBe(42); + expect(meta.date).toBe("2024-07-04"); + expect(meta.methodName).toBe("mymeth"); + expect(meta.algorithm).toBe("mymeth"); + expect(meta.queryImageUrl).toBe("http://asset.test/img.jpg"); + }); + + test("items without taskId are grouped under 'unknown-task'", () => { + const items = [ + { + score: 0.5, + numberCandidates: 0, + date: "d", + methodName: "m", + methodDescription: "d", + queryEncounterImageAsset: null, + queryEncounterImageUrl: null, + queryEncounterAnnotation: {}, + taskStatus: null, + taskStatusOverall: null, + algorithm: "m", + }, + ]; + const sections = store._processData(items); + expect(sections[0].taskId).toBe("unknown-task"); + }); +}); + +// --------------------------------------------------------------------------- + +describe("MatchResultsStore — currentViewData computed", () => { + let store; + beforeEach(() => { + store = new MatchResultsStore(); + store.loadData(makeApiResponse()); + }); + + test("returns processedIndivs when viewMode is individual", () => { + store.setViewMode("individual"); + expect(store.currentViewData).toEqual(store.processedIndivs); + }); + + test("returns processedAnnots when viewMode is image", () => { + store.setViewMode("image"); + expect(store.currentViewData).toEqual(store.processedAnnots); + }); +}); + +// --------------------------------------------------------------------------- + +describe("MatchResultsStore — setSelectedMatch", () => { + let store; + beforeEach(() => { + store = new MatchResultsStore(); + }); + + test("does nothing when key is falsy", () => { + store.setSelectedMatch(true, null, "enc-1", "ind-1", "Name"); + expect(store.selectedMatch).toHaveLength(0); + }); + + test("does nothing when encounterId is falsy", () => { + store.setSelectedMatch(true, "key-1", null, "ind-1", "Name"); + expect(store.selectedMatch).toHaveLength(0); + }); + + test("adds a match entry when selected is true", () => { + store.setSelectedMatch(true, "key-1", "enc-1", "ind-1", "Luna"); + expect(store.selectedMatch).toHaveLength(1); + expect(store.selectedMatch[0]).toMatchObject({ + key: "key-1", + encounterId: "enc-1", + individualId: "ind-1", + individualDisplayName: "Luna", + }); + }); + + test("does not add duplicate keys", () => { + store.setSelectedMatch(true, "key-1", "enc-1", "ind-1", "Luna"); + store.setSelectedMatch(true, "key-1", "enc-1", "ind-1", "Luna"); + expect(store.selectedMatch).toHaveLength(1); + }); + + test("removes a match entry when selected is false", () => { + store.setSelectedMatch(true, "key-1", "enc-1", "ind-1", "Luna"); + store.setSelectedMatch(false, "key-1", "enc-1", "ind-1", "Luna"); + expect(store.selectedMatch).toHaveLength(0); + }); + + test("stores null for missing individualId and displayName", () => { + store.setSelectedMatch(true, "key-2", "enc-2", null, null); + expect(store.selectedMatch[0].individualId).toBeNull(); + expect(store.selectedMatch[0].individualDisplayName).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- + +describe("MatchResultsStore — clearSelection / resetSelectionToQuery", () => { + let store; + beforeEach(() => { + store = new MatchResultsStore(); + store.setSelectedMatch(true, "k1", "e1", "i1", "N1"); + store._matchRequestError = "SOME_ERROR"; + }); + + test("clearSelection empties selectedMatch and clears error", () => { + store.clearSelection(); + expect(store.selectedMatch).toEqual([]); + expect(store.matchRequestError).toBeNull(); + }); + + test("resetSelectionToQuery empties selectedMatch and clears error", () => { + store.resetSelectionToQuery(); + expect(store.selectedMatch).toEqual([]); + expect(store.matchRequestError).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- + +describe("MatchResultsStore — computed: querySelectionItem", () => { + let store; + beforeEach(() => { + store = new MatchResultsStore(); + }); + + test("returns null when encounterId is not set", () => { + expect(store.querySelectionItem).toBeNull(); + }); + + test("returns object with encounterId, individualId, individualDisplayName after loadData", () => { + store.loadData(makeApiResponse()); + const q = store.querySelectionItem; + expect(q).not.toBeNull(); + expect(q.encounterId).toBe("enc-query"); + expect(q.individualId).toBe("ind-query"); + expect(q.individualDisplayName).toBe("Luna"); + }); +}); + +// --------------------------------------------------------------------------- + +describe("MatchResultsStore — computed: selectedIncludingQuery", () => { + let store; + beforeEach(() => { + store = new MatchResultsStore(); + store.loadData(makeApiResponse()); + }); + + test("includes query item first", () => { + const result = store.selectedIncludingQuery; + expect(result[0].encounterId).toBe("enc-query"); + }); + + test("appends additional selected matches without the query duplicate", () => { + store.setSelectedMatch(true, "k1", "enc-other", "ind-other", "Other"); + const result = store.selectedIncludingQuery; + expect(result).toHaveLength(2); + expect(result[1].encounterId).toBe("enc-other"); + }); + + test("deduplicates query encounter from selected list", () => { + store.setSelectedMatch(true, "k1", "enc-query", "ind-query", "Luna"); + const result = store.selectedIncludingQuery; + const queryCount = result.filter( + (m) => m.encounterId === "enc-query", + ).length; + expect(queryCount).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- + +describe("MatchResultsStore — computed: uniqueIndividualIds", () => { + let store; + beforeEach(() => { + store = new MatchResultsStore(); + }); + + test("returns empty array when no individual and no selection", () => { + expect(store.uniqueIndividualIds).toEqual([]); + }); + + test("includes individualId from loadData", () => { + store.loadData(makeApiResponse()); + expect(store.uniqueIndividualIds).toContain("ind-query"); + }); + + test("includes individualIds from selections without duplicates", () => { + store.loadData(makeApiResponse()); + store.setSelectedMatch(true, "k1", "enc-1", "ind-query", "Luna"); + store.setSelectedMatch(true, "k2", "enc-2", "ind-other", "Other"); + const ids = store.uniqueIndividualIds; + expect(ids).toContain("ind-query"); + expect(ids).toContain("ind-other"); + expect(ids.filter((id) => id === "ind-query")).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- + +describe("MatchResultsStore — computed: matchingState", () => { + let store; + beforeEach(() => { + store = new MatchResultsStore(); + }); + + test("returns 'no_individuals' when no encounters in query or selection", () => { + // No encounterId set → querySelectionItem is null → selectedIncludingQuery = [] + expect(store.matchingState).toBe("no_individuals"); + }); + + test("returns 'no_individuals' when query has no individualId and no selections", () => { + store.loadData( + makeApiResponse({ + matchResults: { + ...makeProspect().matchResults, + queryAnnotation: { + ...makeProspect().matchResults.queryAnnotation, + individual: null, + }, + prospects: { + annot: [{ annotId: "a1" }], + indiv: [{ individualId: "i1" }], + }, + }, + }), + ); + expect(store.matchingState).toBe("no_individuals"); + }); + + test("returns 'no_further_action_needed' when all encounters have same individual", () => { + store.loadData(makeApiResponse()); + store.setSelectedMatch(true, "k1", "enc-other", "ind-query", "Luna"); + expect(store.matchingState).toBe("no_further_action_needed"); + }); + + test("returns 'single_individual' when one individual but not all have it", () => { + store.loadData(makeApiResponse()); + // Add a selection without individual — query has ind-query, selected has no individual + store.setSelectedMatch(true, "k1", "enc-no-ind", null, null); + expect(store.matchingState).toBe("single_individual"); + }); + + test("returns 'two_individuals' when exactly two distinct individuals present", () => { + store.loadData(makeApiResponse()); + store.setSelectedMatch(true, "k1", "enc-other", "ind-other", "Other"); + expect(store.matchingState).toBe("two_individuals"); + }); + + test("returns 'too_many_individuals' when more than two distinct individuals", () => { + store.loadData(makeApiResponse()); + store.setSelectedMatch(true, "k1", "enc-2", "ind-2", "Two"); + store.setSelectedMatch(true, "k2", "enc-3", "ind-3", "Three"); + expect(store.matchingState).toBe("too_many_individuals"); + }); +}); + +// --------------------------------------------------------------------------- + +describe("MatchResultsStore — handleNoFurtherActionNeeded", () => { + test("returns { ok: true, noop: true } and clears selection", () => { + const store = new MatchResultsStore(); + store.setSelectedMatch(true, "k1", "enc-1", "ind-1", "N"); + const result = store.handleNoFurtherActionNeeded(); + expect(result).toEqual({ ok: true, noop: true }); + expect(store.selectedMatch).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- + +describe("MatchResultsStore — fetchMatchResults", () => { + let store; + beforeEach(() => { + store = new MatchResultsStore(); + jest.clearAllMocks(); + }); + + test("does nothing when taskId is not set", async () => { + await store.fetchMatchResults(); + expect(axios.get).not.toHaveBeenCalled(); + }); + + test("calls correct endpoint with prospectsSize param", async () => { + store.setTaskId("task-abc"); + store._numResults = 5; + axios.get.mockResolvedValueOnce({ data: makeApiResponse() }); + await store.fetchMatchResults(); + expect(axios.get).toHaveBeenCalledWith( + expect.stringContaining("/api/v3/tasks/task-abc/match-results"), + ); + expect(axios.get).toHaveBeenCalledWith( + expect.stringContaining("prospectsSize=5"), + ); + }); + + test("appends projectId params when projectNames is set", async () => { + store.setTaskId("task-xyz"); + store._projectNames = ["proj-a", "proj-b"]; + axios.get.mockResolvedValueOnce({ data: makeApiResponse() }); + await store.fetchMatchResults(); + const url = axios.get.mock.calls[0][0]; + expect(url).toContain("projectId=proj-a"); + expect(url).toContain("projectId=proj-b"); + }); + + test("sets loading to false after success", async () => { + store.setTaskId("t1"); + axios.get.mockResolvedValueOnce({ data: makeApiResponse() }); + await store.fetchMatchResults(); + expect(store.loading).toBe(false); + }); + + test("sets loading to false after error", async () => { + store.setTaskId("t1"); + axios.get.mockRejectedValueOnce(new Error("network error")); + await store.fetchMatchResults(); + expect(store.loading).toBe(false); + }); + + test("loads data into store after successful fetch", async () => { + store.setTaskId("t1"); + axios.get.mockResolvedValueOnce({ data: makeApiResponse() }); + await store.fetchMatchResults(); + expect(store.hasResults).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- + +describe("MatchResultsStore — handleCreateNewIndividual", () => { + let store; + beforeEach(() => { + store = new MatchResultsStore(); + store.loadData(makeApiResponse()); + jest.clearAllMocks(); + }); + + test("returns error when newIndividualName is empty", async () => { + store.setNewIndividualName(""); + const result = await store.handleCreateNewIndividual(null); + expect(result).toEqual({ ok: false, error: "ENTER_INDIVIDUAL_NAME" }); + expect(store.matchRequestError).toBe("ENTER_INDIVIDUAL_NAME"); + }); + + test("returns error when newIndividualName is only whitespace", async () => { + store.setNewIndividualName(" "); + const result = await store.handleCreateNewIndividual(null); + expect(result).toEqual({ ok: false, error: "ENTER_INDIVIDUAL_NAME" }); + }); + + test("patches encounters for unassigned encounters and returns ok:true on success", async () => { + store.setNewIndividualName("Nemo"); + // Add a selection without individual so it gets patched + store.setSelectedMatch(true, "k1", "enc-no-ind", null, null); + axios.patch.mockResolvedValue({ data: {} }); + const result = await store.handleCreateNewIndividual(null); + expect(result.ok).toBe(true); + expect(axios.patch).toHaveBeenCalled(); + }); + + test("includes identificationRemarks patch op when remark is provided", async () => { + store.setNewIndividualName("Nemo"); + store.setSelectedMatch(true, "k1", "enc-no-ind", null, null); + axios.patch.mockResolvedValue({ data: {} }); + await store.handleCreateNewIndividual("remark-value"); + const patchOps = axios.patch.mock.calls[0][1]; + const remarkOp = patchOps.find((op) => op.path === "identificationRemarks"); + expect(remarkOp).toBeDefined(); + expect(remarkOp.value).toBe("remark-value"); + }); + + test("does not include identificationRemarks op when remark is empty", async () => { + store.setNewIndividualName("Nemo"); + store.setSelectedMatch(true, "k1", "enc-no-ind", null, null); + axios.patch.mockResolvedValue({ data: {} }); + await store.handleCreateNewIndividual(""); + const patchOps = axios.patch.mock.calls[0][1]; + const remarkOp = patchOps.find((op) => op.path === "identificationRemarks"); + expect(remarkOp).toBeUndefined(); + }); + + test("sets matchRequestError on axios failure", async () => { + store.setNewIndividualName("Nemo"); + store.setSelectedMatch(true, "k1", "enc-no-ind", null, null); + axios.patch.mockRejectedValueOnce(new Error("server error")); + const result = await store.handleCreateNewIndividual(null); + expect(result).toEqual({ + ok: false, + error: "CREATE_NEW_INDIVIDUAL_FAILED", + }); + expect(store.matchRequestError).toBe("CREATE_NEW_INDIVIDUAL_FAILED"); + }); + + test("sets matchRequestLoading to false in finally block", async () => { + store.setNewIndividualName(""); + await store.handleCreateNewIndividual(null); + expect(store.matchRequestLoading).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- + +describe("MatchResultsStore — handleMatch", () => { + let store; + beforeEach(() => { + store = new MatchResultsStore(); + store.loadData(makeApiResponse()); // query has ind-query + jest.clearAllMocks(); + }); + + test("sets error when there is not exactly one unique individual", async () => { + // query has ind-query, adding ind-other → two unique individuals + store.setSelectedMatch(true, "k1", "enc-other", "ind-other", "Other"); + const result = await store.handleMatch(); + expect(result).toBeNull(); + expect(store.matchRequestError).toBe("MATCH_REQUIRES_SINGLE_INDIVIDUAL"); + }); + + test("calls iaResultsSetID.jsp with correct params when one individual present", async () => { + // query already has ind-query; add selection with same individual plus unnamed + store.setSelectedMatch(true, "k1", "enc-no-ind", null, null); + axios.get.mockResolvedValueOnce({ data: { success: true } }); + const result = await store.handleMatch(); + expect(result).toEqual({ success: true }); + const url = axios.get.mock.calls[0][0]; + expect(url).toContain("/iaResultsSetID.jsp"); + expect(url).toContain("individualID=ind-query"); + }); + + test("sets matchRequestError on failure", async () => { + store.setSelectedMatch(true, "k1", "enc-no-ind", null, null); + axios.get.mockRejectedValueOnce(new Error("fail")); + const result = await store.handleMatch(); + expect(result).toBeNull(); + expect(store.matchRequestError).toBe("MATCH_FAILED"); + }); + + test("resets selection on success", async () => { + store.setSelectedMatch(true, "k1", "enc-no-ind", null, null); + axios.get.mockResolvedValueOnce({ data: {} }); + await store.handleMatch(); + expect(store.selectedMatch).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- + +describe("MatchResultsStore — handleMerge", () => { + let store; + beforeEach(() => { + store = new MatchResultsStore(); + store.loadData(makeApiResponse()); // query has ind-query + jest.clearAllMocks(); + delete window.open; + window.open = jest.fn(); + }); + + test("sets error when there are not exactly two unique individuals", async () => { + // Only one individual (ind-query) + const result = await store.handleMerge(); + expect(result).toBeNull(); + expect(store.matchRequestError).toBe("MERGE_REQUIRES_TWO_INDIVIDUALS"); + }); + + test("opens merge.jsp with correct params when two individuals are present", async () => { + store.setSelectedMatch(true, "k1", "enc-other", "ind-other", "Other"); + const result = await store.handleMerge(); + expect(result).toEqual({ ok: true }); + expect(window.open).toHaveBeenCalledWith( + expect.stringContaining("/merge.jsp"), + "_blank", + ); + const url = window.open.mock.calls[0][0]; + expect(url).toContain("individualA=ind-query"); + expect(url).toContain("individualB=ind-other"); + }); + + test("sets matchRequestError on failure", async () => { + store.setSelectedMatch(true, "k1", "enc-other", "ind-other", "Other"); + window.open = jest.fn(() => { + throw new Error("blocked"); + }); + const result = await store.handleMerge(); + expect(result).toBeNull(); + expect(store.matchRequestError).toBe("MERGE_FAILED"); + }); + + test("resets selection on success", async () => { + store.setSelectedMatch(true, "k1", "enc-other", "ind-other", "Other"); + await store.handleMerge(); + expect(store.selectedMatch).toEqual([]); + }); +}); diff --git a/frontend/src/__tests__/pages/ReportAnEncounterPage/ImageSection.test.js b/frontend/src/__tests__/pages/ReportAnEncounterPage/ImageSection.test.js index 46a3df73d1..fe2ef189ad 100644 --- a/frontend/src/__tests__/pages/ReportAnEncounterPage/ImageSection.test.js +++ b/frontend/src/__tests__/pages/ReportAnEncounterPage/ImageSection.test.js @@ -4,9 +4,10 @@ import userEvent from "@testing-library/user-event"; import { ImageSection } from "../../../pages/ReportsAndManagamentPages/ImageSection"; import { ReportEncounterStore } from "../../../pages/ReportsAndManagamentPages/ReportEncounterStore"; import { renderWithProviders } from "../../../utils/utils"; +import { useSiteSettings } from "../../../SiteSettingsContext"; -jest.mock("../../../models/useGetSiteSettings", () => () => ({ - data: { maximumMediaSizeMegabytes: 40 }, +jest.mock("../../../SiteSettingsContext", () => ({ + useSiteSettings: jest.fn(), })); jest.mock( @@ -37,6 +38,14 @@ const renderComponent = () => { }; describe("ImageSection Component", () => { + beforeEach(() => { + useSiteSettings.mockReturnValue({ + data: { maximumMediaSizeMegabytes: 40 }, + isLoading: false, + error: null, + }); + }); + test("renders the component correctly", () => { renderComponent(); expect(screen.getByText(/PHOTOS_SECTION/i)).toBeInTheDocument(); diff --git a/frontend/src/__tests__/pages/ReportAnEncounterPage/PlaceSection.test.js b/frontend/src/__tests__/pages/ReportAnEncounterPage/PlaceSection.test.js index e20277aaff..d169cecc95 100644 --- a/frontend/src/__tests__/pages/ReportAnEncounterPage/PlaceSection.test.js +++ b/frontend/src/__tests__/pages/ReportAnEncounterPage/PlaceSection.test.js @@ -1,11 +1,13 @@ import React from "react"; import { screen, fireEvent, waitFor } from "@testing-library/react"; import { PlaceSection } from "../../../pages/ReportsAndManagamentPages/PlaceSection"; -import useGetSiteSettings from "../../../models/useGetSiteSettings"; +import { useSiteSettings } from "../../../SiteSettingsContext"; import { renderWithProviders } from "../../../utils/utils"; import { ReportEncounterStore } from "../../../pages/ReportsAndManagamentPages/ReportEncounterStore"; -jest.mock("../../../models/useGetSiteSettings"); +jest.mock("../../../SiteSettingsContext", () => ({ + useSiteSettings: jest.fn(), +})); jest.mock("@googlemaps/js-api-loader", () => ({ Loader: jest.fn().mockImplementation(() => ({ load: jest.fn().mockResolvedValue({}), @@ -35,7 +37,7 @@ const renderComponent = () => { }; describe("PlaceSection Component", () => { beforeEach(() => { - useGetSiteSettings.mockReturnValue({ + useSiteSettings.mockReturnValue({ data: { mapCenterLat: 51, mapCenterLon: 7, @@ -43,6 +45,8 @@ describe("PlaceSection Component", () => { googleMapsKey: "test-key", locationData: [{ locationID: "test-id" }], }, + isLoading: false, + error: null, }); }); diff --git a/frontend/src/__tests__/pages/ReportAnEncounterPage/ReportAnEncounter.test.js b/frontend/src/__tests__/pages/ReportAnEncounterPage/ReportAnEncounter.test.js index b99eeaaedc..d5cf52a423 100644 --- a/frontend/src/__tests__/pages/ReportAnEncounterPage/ReportAnEncounter.test.js +++ b/frontend/src/__tests__/pages/ReportAnEncounterPage/ReportAnEncounter.test.js @@ -2,7 +2,7 @@ import React from "react"; import { screen, fireEvent, waitFor } from "@testing-library/react"; import { renderWithProviders } from "../../../utils/utils"; import ReportEncounter from "../../../pages/ReportsAndManagamentPages/ReportEncounter"; -import useGetSiteSettings from "../../../models/useGetSiteSettings"; +import { useSiteSettings } from "../../../SiteSettingsContext"; beforeEach(() => { jest.spyOn(Storage.prototype, "removeItem"); @@ -12,11 +12,9 @@ afterEach(() => { jest.restoreAllMocks(); }); -jest.mock("../../../models/useGetSiteSettings", () => ({ +jest.mock("../../../SiteSettingsContext", () => ({ __esModule: true, - default: jest.fn(() => ({ - data: { procaptchaSiteKey: "mock-key", isHuman: true }, - })), + useSiteSettings: jest.fn(), })); global.mockProCaptchaRender = jest.fn(); @@ -74,9 +72,11 @@ const renderComponent = () => renderWithProviders(, false); describe("ReportEncounter Component", () => { beforeEach(() => { jest.clearAllMocks(); - useGetSiteSettings.mockImplementation(() => ({ + useSiteSettings.mockReturnValue({ data: { procaptchaSiteKey: "mock-key", isHuman: true }, - })); + isLoading: false, + error: null, + }); }); test("renders component correctly", () => { diff --git a/frontend/src/__tests__/pages/ReportAnEncounterPage/SpeciesSection.test.js b/frontend/src/__tests__/pages/ReportAnEncounterPage/SpeciesSection.test.js index 31fd5a2880..492ed3d12e 100644 --- a/frontend/src/__tests__/pages/ReportAnEncounterPage/SpeciesSection.test.js +++ b/frontend/src/__tests__/pages/ReportAnEncounterPage/SpeciesSection.test.js @@ -1,10 +1,12 @@ import React from "react"; import { screen, fireEvent } from "@testing-library/react"; import { ReportEncounterSpeciesSection } from "../../../pages/ReportsAndManagamentPages/SpeciesSection"; -import useGetSiteSettings from "../../../models/useGetSiteSettings"; +import { useSiteSettings } from "../../../SiteSettingsContext"; import { renderWithProviders } from "../../../utils/utils"; -jest.mock("../../../models/useGetSiteSettings", () => jest.fn()); +jest.mock("../../../SiteSettingsContext", () => ({ + useSiteSettings: jest.fn(), +})); const mockStore = { speciesSection: { @@ -22,13 +24,15 @@ describe("ReportEncounterSpeciesSection Component", () => { }); it("renders correctly with required fields", () => { - useGetSiteSettings.mockReturnValue({ + useSiteSettings.mockReturnValue({ data: { siteTaxonomies: [ { scientificName: "Panthera leo" }, { scientificName: "Canis lupus" }, ], }, + isLoading: false, + error: null, }); renderWithProviders(); @@ -42,13 +46,15 @@ describe("ReportEncounterSpeciesSection Component", () => { }); it("displays the correct options in the select dropdown", () => { - useGetSiteSettings.mockReturnValue({ + useSiteSettings.mockReturnValue({ data: { siteTaxonomies: [ { scientificName: "Panthera leo" }, { scientificName: "Canis lupus" }, ], }, + isLoading: false, + error: null, }); renderWithProviders(); @@ -61,13 +67,15 @@ describe("ReportEncounterSpeciesSection Component", () => { }); it("calls setSpeciesSectionValue when an option is selected", () => { - useGetSiteSettings.mockReturnValue({ + useSiteSettings.mockReturnValue({ data: { siteTaxonomies: [ { scientificName: "Panthera leo" }, { scientificName: "Canis lupus" }, ], }, + isLoading: false, + error: null, }); renderWithProviders(); @@ -81,8 +89,10 @@ describe("ReportEncounterSpeciesSection Component", () => { }); it("displays error message when species selection is required but not provided", () => { - useGetSiteSettings.mockReturnValue({ + useSiteSettings.mockReturnValue({ data: { siteTaxonomies: [] }, + isLoading: false, + error: null, }); mockStore.speciesSection.error = true; @@ -93,10 +103,12 @@ describe("ReportEncounterSpeciesSection Component", () => { }); it("allows selecting 'Unknown' as an option", () => { - useGetSiteSettings.mockReturnValue({ + useSiteSettings.mockReturnValue({ data: { siteTaxonomies: [{ scientificName: "Panthera leo" }], }, + isLoading: false, + error: null, }); renderWithProviders(); diff --git a/frontend/src/__tests__/pages/login/LoginPageAuthenticate.test.js b/frontend/src/__tests__/pages/login/LoginPageAuthenticate.test.js index 3901aba5ef..4604a13dff 100644 --- a/frontend/src/__tests__/pages/login/LoginPageAuthenticate.test.js +++ b/frontend/src/__tests__/pages/login/LoginPageAuthenticate.test.js @@ -4,9 +4,19 @@ import React from "react"; import { fireEvent, screen } from "@testing-library/react"; import LoginPage from "../../../pages/Login"; import { renderWithProviders } from "../../../utils/utils"; +import { useSiteSettings } from "../../../SiteSettingsContext"; + +jest.mock("../../../SiteSettingsContext", () => ({ + useSiteSettings: jest.fn(), +})); test("calls authenticate function on submit", () => { const mockAuthenticate = jest.fn(); + useSiteSettings.mockReturnValue({ + data: {}, + isLoading: false, + error: null, + }); useLogin.mockReturnValue({ authenticate: mockAuthenticate, error: null, diff --git a/frontend/src/__tests__/pages/login/LoginPageButtonState.test.js b/frontend/src/__tests__/pages/login/LoginPageButtonState.test.js index c50a91d0a8..f3c87b076e 100644 --- a/frontend/src/__tests__/pages/login/LoginPageButtonState.test.js +++ b/frontend/src/__tests__/pages/login/LoginPageButtonState.test.js @@ -4,11 +4,20 @@ import React from "react"; import LoginPage from "../../../pages/Login"; import { renderWithProviders } from "../../../utils/utils"; import { screen } from "@testing-library/react"; +import { useSiteSettings } from "../../../SiteSettingsContext"; jest.mock("../../../models/auth/useLogin"); +jest.mock("../../../SiteSettingsContext", () => ({ + useSiteSettings: jest.fn(), +})); describe("LoginPage - Button State", () => { test("disables submit button when loading", () => { + useSiteSettings.mockReturnValue({ + data: {}, + isLoading: false, + error: null, + }); useLogin.mockReturnValue({ authenticate: jest.fn(), error: null, diff --git a/frontend/src/__tests__/pages/login/LoginPageError.test.js b/frontend/src/__tests__/pages/login/LoginPageError.test.js index 3c4d01c4bb..580fa842b4 100644 --- a/frontend/src/__tests__/pages/login/LoginPageError.test.js +++ b/frontend/src/__tests__/pages/login/LoginPageError.test.js @@ -1,36 +1,36 @@ import React from "react"; -import { fireEvent, screen, waitFor, act } from "@testing-library/react"; +import { fireEvent, screen, act } from "@testing-library/react"; import LoginPage from "../../../pages/Login"; import useLogin from "../../../models/auth/useLogin"; import { renderWithProviders } from "../../../utils/utils"; +import { useSiteSettings } from "../../../SiteSettingsContext"; jest.mock("../../../models/auth/useLogin"); +jest.mock("../../../SiteSettingsContext", () => ({ + useSiteSettings: jest.fn(), +})); describe("LoginPage Tests", () => { beforeEach(() => { jest.clearAllMocks(); - let errorState = null; - const mockSetError = jest.fn((errorMessage) => { - errorState = errorMessage; - useLogin.mockReturnValue({ - authenticate: mockAuthenticate, - error: errorState, - setError: mockSetError, - loading: false, - }); - }); - - const mockAuthenticate = jest.fn(async () => { - mockSetError("Invalid email or password"); + useSiteSettings.mockReturnValue({ + data: {}, + isLoading: false, + error: null, }); + mockSetError = jest.fn(); + mockAuthenticate = jest.fn(); useLogin.mockReturnValue({ authenticate: mockAuthenticate, - error: errorState, + error: null, setError: mockSetError, loading: false, }); }); + let mockAuthenticate; + let mockSetError; + test("should show error message when submitting empty username and password", async () => { renderWithProviders(); @@ -38,11 +38,8 @@ describe("LoginPage Tests", () => { screen.getByRole("button", { name: /sign in/i }).click(); }); - await waitFor(() => { - expect( - screen.getByText(/Invalid email or password/i), - ).toBeInTheDocument(); - }); + expect(mockSetError).toHaveBeenCalledWith(null); + expect(mockAuthenticate).toHaveBeenCalledWith("", ""); }); test("should show error when entering incorrect credentials", async () => { @@ -60,10 +57,7 @@ describe("LoginPage Tests", () => { screen.getByRole("button", { name: /sign in/i }).click(); }); - await waitFor(() => { - expect( - screen.getByText(/Invalid email or password/i), - ).toBeInTheDocument(); - }); + expect(mockSetError).toHaveBeenCalledWith(null); + expect(mockAuthenticate).toHaveBeenCalledWith("wrongUser", "wrongPass"); }); }); diff --git a/frontend/src/__tests__/pages/login/LoginPageInput.test.js b/frontend/src/__tests__/pages/login/LoginPageInput.test.js index 512601bb8d..4fcb751b8d 100644 --- a/frontend/src/__tests__/pages/login/LoginPageInput.test.js +++ b/frontend/src/__tests__/pages/login/LoginPageInput.test.js @@ -3,8 +3,18 @@ import { renderWithProviders } from "../../../utils/utils"; import LoginPage from "../../../pages/Login"; import { fireEvent, screen, act } from "@testing-library/react"; import "@testing-library/jest-dom"; +import { useSiteSettings } from "../../../SiteSettingsContext"; + +jest.mock("../../../SiteSettingsContext", () => ({ + useSiteSettings: jest.fn(), +})); test("allows user to type username and password", () => { + useSiteSettings.mockReturnValue({ + data: {}, + isLoading: false, + error: null, + }); renderWithProviders(); const usernameInput = screen.getByPlaceholderText("Username"); diff --git a/frontend/src/__tests__/pages/login/LoginPageLinks.test.js b/frontend/src/__tests__/pages/login/LoginPageLinks.test.js index db09e68cf3..9e939d8733 100644 --- a/frontend/src/__tests__/pages/login/LoginPageLinks.test.js +++ b/frontend/src/__tests__/pages/login/LoginPageLinks.test.js @@ -2,8 +2,21 @@ import React from "react"; import { renderWithProviders } from "../../../utils/utils"; import LoginPage from "../../../pages/Login"; import { screen } from "@testing-library/react"; +import { useSiteSettings } from "../../../SiteSettingsContext"; + +jest.mock("../../../SiteSettingsContext", () => ({ + useSiteSettings: jest.fn(), +})); describe("LoginPage - Links", () => { + beforeEach(() => { + useSiteSettings.mockReturnValue({ + data: {}, + isLoading: false, + error: null, + }); + }); + test('renders "Forgot Password" link with correct href', () => { renderWithProviders(); diff --git a/frontend/src/__tests__/pages/login/LoginPagePasswordToggle.test.js b/frontend/src/__tests__/pages/login/LoginPagePasswordToggle.test.js index 0e0875e279..c97e84cdb0 100644 --- a/frontend/src/__tests__/pages/login/LoginPagePasswordToggle.test.js +++ b/frontend/src/__tests__/pages/login/LoginPagePasswordToggle.test.js @@ -4,8 +4,18 @@ import { renderWithProviders } from "../../../utils/utils"; import LoginPage from "../../../pages/Login"; import { screen, act } from "@testing-library/react"; import "@testing-library/jest-dom"; +import { useSiteSettings } from "../../../SiteSettingsContext"; + +jest.mock("../../../SiteSettingsContext", () => ({ + useSiteSettings: jest.fn(), +})); test("allows user to type username and password", async () => { + useSiteSettings.mockReturnValue({ + data: {}, + isLoading: false, + error: null, + }); renderWithProviders(); const passwordInput = screen.getByPlaceholderText("Password"); diff --git a/frontend/src/__tests__/pages/login/LoginPageRender.test.js b/frontend/src/__tests__/pages/login/LoginPageRender.test.js index 88192b779f..efd55fd7ae 100644 --- a/frontend/src/__tests__/pages/login/LoginPageRender.test.js +++ b/frontend/src/__tests__/pages/login/LoginPageRender.test.js @@ -3,8 +3,18 @@ import { screen } from "@testing-library/react"; import LoginPage from "../../../pages/Login"; import "@testing-library/jest-dom"; import { renderWithProviders } from "../../../utils/utils"; +import { useSiteSettings } from "../../../SiteSettingsContext"; + +jest.mock("../../../SiteSettingsContext", () => ({ + useSiteSettings: jest.fn(), +})); test("renders login page correctly", () => { + useSiteSettings.mockReturnValue({ + data: {}, + isLoading: false, + error: null, + }); renderWithProviders(); expect(screen.getByPlaceholderText("Username")).toBeInTheDocument(); diff --git a/frontend/src/__tests__/pages/login/LoginPageSubmit.test.js b/frontend/src/__tests__/pages/login/LoginPageSubmit.test.js index 73fdd31047..8ec47f0628 100644 --- a/frontend/src/__tests__/pages/login/LoginPageSubmit.test.js +++ b/frontend/src/__tests__/pages/login/LoginPageSubmit.test.js @@ -8,13 +8,22 @@ import { clickButton, } from "../../../utils/utils"; import { waitFor } from "@testing-library/react"; +import { useSiteSettings } from "../../../SiteSettingsContext"; jest.mock("../../../models/auth/useLogin"); +jest.mock("../../../SiteSettingsContext", () => ({ + useSiteSettings: jest.fn(), +})); describe("LoginPage - Form Submission", () => { let mockAuthenticate; beforeEach(() => { + useSiteSettings.mockReturnValue({ + data: {}, + isLoading: false, + error: null, + }); mockAuthenticate = jest.fn(); useLogin.mockReturnValue({ authenticate: mockAuthenticate, diff --git a/frontend/src/components/AnnotationOverlay.jsx b/frontend/src/components/AnnotationOverlay.jsx new file mode 100644 index 0000000000..8d8fd20ed3 --- /dev/null +++ b/frontend/src/components/AnnotationOverlay.jsx @@ -0,0 +1,394 @@ +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; + +const VISIBLE_MARGIN_PX = 40; + +const InteractiveAnnotationOverlay = forwardRef( + ( + { + imageUrl, + annotations = [], + originalWidth = 0, + originalHeight = 0, + rotationInfo = null, + initialZoom = 1, + minZoom = 1, + maxZoom = 3, + zoomStep = 0.25, + showAnnotations: showAnnotationsProp, + strokeColor = "red", + lineWidth = 2, + containerStyle = {}, + imageStyle = {}, + overlayStyle = {}, + loadingText = "Loading image...", + loadingOverlayStyle = {}, + alt = "Image with annotations", + }, + ref, + ) => { + const outerContainerRef = useRef(null); + const imgRef = useRef(null); + + const [zoom, setZoom] = useState( + Number.isFinite(initialZoom) ? initialZoom : 1, + ); + const [pan, setPan] = useState({ x: 0, y: 0 }); + const [dragging, setDragging] = useState(false); + const dragStartRef = useRef({ x: 0, y: 0 }); + const panStartRef = useRef({ x: 0, y: 0 }); + const [scaleX, setScaleX] = useState(1); + const [scaleY, setScaleY] = useState(1); + const [imageLoaded, setImageLoaded] = useState(false); + + const [internalShowAnn, setInternalShowAnn] = useState(true); + const showAnn = + typeof showAnnotationsProp === "boolean" + ? showAnnotationsProp + : internalShowAnn; + + const hasRotation = !!rotationInfo; + + useEffect(() => { + if (!imgRef.current) return; + + setImageLoaded(false); + + const handleImageLoad = () => { + if (imgRef.current) { + const naturalWidth = Number(originalWidth); + const naturalHeight = Number(originalHeight); + const displayWidth = imgRef.current.clientWidth; + const displayHeight = imgRef.current.clientHeight; + + if (naturalWidth && naturalHeight && displayWidth && displayHeight) { + setScaleX(naturalWidth / displayWidth); + setScaleY(naturalHeight / displayHeight); + } else { + setScaleX(1); + setScaleY(1); + } + + setImageLoaded(true); + } + }; + + const imgElement = imgRef.current; + + if (imgElement && imgElement.complete && imgElement.naturalWidth > 0) { + handleImageLoad(); + } else if (imgElement) { + imgElement.addEventListener("load", handleImageLoad); + } + + return () => { + if (imgElement) { + imgElement.removeEventListener("load", handleImageLoad); + } + }; + }, [originalWidth, originalHeight, imageUrl]); + + const canRenderAnnotations = useMemo(() => { + return ( + imageLoaded && + showAnn && + Number.isFinite(scaleX) && + Number.isFinite(scaleY) && + scaleX > 0 && + scaleY > 0 + ); + }, [imageLoaded, showAnn, scaleX, scaleY]); + + const visibleAnnotations = useMemo(() => { + if (!Array.isArray(annotations)) return []; + + const isFiniteNum = (v) => Number.isFinite(Number(v)); + + return annotations + .filter((a) => a && !a.trivial && !a.isTrivial) + .filter((a) => { + const x = Number(a.x); + const y = Number(a.y); + const w = Number(a.width); + const h = Number(a.height); + + if (![x, y, w, h].every(isFiniteNum)) return false; + if (w <= 0 || h <= 0) return false; + + return true; + }); + }, [annotations]); + + const clampZoom = (z) => Math.max(minZoom, Math.min(maxZoom, z)); + + const clampPan = (nextPan, nextZoom = zoom) => { + const container = outerContainerRef.current; + const img = imgRef.current; + + if (!container || !img) return nextPan; + + const containerWidth = container.clientWidth; + const containerHeight = container.clientHeight; + const imageWidth = img.clientWidth * nextZoom; + const imageHeight = img.clientHeight * nextZoom; + + const visibleMarginX = Math.min(VISIBLE_MARGIN_PX, containerWidth); + const visibleMarginY = Math.min(VISIBLE_MARGIN_PX, containerHeight); + + const minX = visibleMarginX - imageWidth; + const maxX = containerWidth - visibleMarginX; + const minY = visibleMarginY - imageHeight; + const maxY = containerHeight - visibleMarginY; + + return { + x: Math.max(minX, Math.min(maxX, nextPan.x)), + y: Math.max(minY, Math.min(maxY, nextPan.y)), + }; + }; + + const stateRef = useRef({ zoom, pan, showAnn, imageLoaded }); + useEffect(() => { + stateRef.current = { zoom, pan, showAnn, imageLoaded }; + }, [zoom, pan, showAnn, imageLoaded]); + + useImperativeHandle(ref, () => ({ + zoomIn: () => { + setZoom((z) => { + const nextZoom = clampZoom(z + zoomStep); + setPan((prev) => clampPan(prev, nextZoom)); + return nextZoom; + }); + }, + zoomOut: () => { + setZoom((z) => { + const nextZoom = clampZoom(z - zoomStep); + setPan((prev) => clampPan(prev, nextZoom)); + return nextZoom; + }); + }, + reset: () => { + const nextZoom = clampZoom(initialZoom || 1); + setZoom(nextZoom); + setPan(clampPan({ x: 0, y: 0 }, nextZoom)); + }, + toggleAnnotations: () => { + if (typeof showAnnotationsProp === "boolean") return; + setInternalShowAnn((v) => !v); + }, + setAnnotationsVisible: (v) => { + if (typeof showAnnotationsProp === "boolean") return; + setInternalShowAnn(!!v); + }, + getState: () => stateRef.current, + }), []); + + const onMouseDown = (e) => { + if (!imageLoaded) return; + + setDragging(true); + dragStartRef.current = { x: e.clientX, y: e.clientY }; + panStartRef.current = { ...pan }; + }; + + useEffect(() => { + if (!dragging) return; + + const onMove = (e) => { + const dx = e.clientX - dragStartRef.current.x; + const dy = e.clientY - dragStartRef.current.y; + + const nextPan = { + x: panStartRef.current.x + dx, + y: panStartRef.current.y + dy, + }; + + setPan(clampPan(nextPan)); + }; + + const onUp = () => setDragging(false); + + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + + return () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + }, [dragging, zoom]); + + useEffect(() => { + const img = imgRef.current; + if (!img) return; + + const handleLoad = () => { + setPan((prev) => clampPan(prev, zoom)); + }; + + if (img.complete && img.naturalWidth > 0) { + handleLoad(); + } else { + img.addEventListener("load", handleLoad); + return () => img.removeEventListener("load", handleLoad); + } + }, [imageUrl, zoom]); + + useEffect(() => { + const handleResize = () => { + setPan((prev) => clampPan(prev, zoom)); + }; + + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, [zoom]); + + const panZoomTransform = `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`; + + return ( +
+
+ {alt} + + {!imageLoaded && ( +
+ {loadingText} +
+ )} + + {canRenderAnnotations && ( +
+ {visibleAnnotations.map((a, idx) => { + let rect = { + x: Number(a.x), + y: Number(a.y), + width: Number(a.width), + height: Number(a.height), + rotation: Number(a.theta || 0), + }; + + if (hasRotation) { + const imgW = Number(originalWidth); + const imgH = Number(originalHeight); + const adjW = imgH / imgW; + const adjH = imgW / imgH; + + rect = { + x: rect.x / scaleX / adjW, + width: rect.width / scaleX / adjW, + y: rect.y / scaleY / adjH, + height: rect.height / scaleY / adjH, + rotation: rect.rotation, + }; + } else { + rect = { + x: rect.x / scaleX, + y: rect.y / scaleY, + width: rect.width / scaleX, + height: rect.height / scaleY, + rotation: rect.rotation, + }; + } + + if ( + !Number.isFinite(rect.width) || + !Number.isFinite(rect.height) || + rect.width <= 0 || + rect.height <= 0 + ) { + return null; + } + + const key = + a.id ?? + a.annotationId ?? + `${idx}-${Number(a.x)}-${Number(a.y)}-${Number(a.width)}-${Number(a.height)}`; + + return ( +
+ ); + })} +
+ )} +
+
+ ); + }, +); + +InteractiveAnnotationOverlay.displayName = "InteractiveAnnotationOverlay"; +export default InteractiveAnnotationOverlay; diff --git a/frontend/src/components/Chip.jsx b/frontend/src/components/Chip.jsx index 5389a2ff4f..3198243e60 100644 --- a/frontend/src/components/Chip.jsx +++ b/frontend/src/components/Chip.jsx @@ -1,8 +1,8 @@ import React from "react"; -import useGetSiteSettings from "../models/useGetSiteSettings"; +import { useSiteSettings } from "../SiteSettingsContext"; function Chip({ children }) { - const { data } = useGetSiteSettings(); + const { data } = useSiteSettings(); function renderFilter(filter) { function getLabelById(options, id) { @@ -28,7 +28,7 @@ function Chip({ children }) { Object.entries(data?.projectsForUser || {})?.map((item) => { return { value: item[0], - label: item[1], + label: item[1]?.name, }; }) || []; diff --git a/frontend/src/components/DataTable.jsx b/frontend/src/components/DataTable.jsx index 04a041e606..4857b37601 100644 --- a/frontend/src/components/DataTable.jsx +++ b/frontend/src/components/DataTable.jsx @@ -12,7 +12,7 @@ import { observer } from "mobx-react-lite"; import GalleryView from "../pages/SearchPages/searchResultTabs/GalleryView"; import Select from "react-select"; import MainButton from "./MainButton"; -import useGetSiteSettings from "../models/useGetSiteSettings"; +import { useSiteSettings } from "../SiteSettingsContext"; const customStyles = { rows: { @@ -221,8 +221,8 @@ const MyDataTable = observer( } }, [store.selectedRows.length, store.projectBannerStatusCode]); - const { data: siteSettingsData, loading: siteSettingsLoading } = - useGetSiteSettings(); + const { data: siteSettingsData, isLoading: siteSettingsLoading } = + useSiteSettings(); useEffect(() => { if (siteSettingsData) { store.setSiteSettingsData(siteSettingsData); @@ -270,7 +270,7 @@ const MyDataTable = observer( const projectOptions = Object.entries( store?.siteSettingsData?.projectsForUser ?? {}, - ).map(([value, label]) => ({ value, label })); + ).map(([value, label]) => ({ value, label: label?.name })); const handleSort = (column, sortDirection) => { const columnName = diff --git a/frontend/src/components/FilterPanel.jsx b/frontend/src/components/FilterPanel.jsx index 4c4ddabdd9..5da661a54f 100644 --- a/frontend/src/components/FilterPanel.jsx +++ b/frontend/src/components/FilterPanel.jsx @@ -4,7 +4,7 @@ import Text from "./Text"; import { Container } from "react-bootstrap"; import ThemeContext from "../ThemeColorProvider"; import BrutalismButton from "./BrutalismButton"; -import useGetSiteSettings from "../models/useGetSiteSettings"; +import { useSiteSettings } from "../SiteSettingsContext"; import { Col, Row } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import { useSearchParams } from "react-router-dom"; @@ -18,7 +18,7 @@ export default function FilterPanel({ setTempFormFilters = () => {}, store, }) { - const { data } = useGetSiteSettings(); + const { data } = useSiteSettings(); const safeSchemas = schemas || []; const [clicked, setClicked] = useState(safeSchemas[0]?.id); const theme = React.useContext(ThemeContext); diff --git a/frontend/src/components/Footer.jsx b/frontend/src/components/Footer.jsx index 3e6acdd0ef..6f6d7de044 100644 --- a/frontend/src/components/Footer.jsx +++ b/frontend/src/components/Footer.jsx @@ -1,10 +1,10 @@ -import React, { useContext, useState, useEffect } from "react"; +import React, { useContext, useEffect, useState } from "react"; import { Container, Row, Col } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import FooterLink from "./footer/FooterLink"; import ThemeColorContext from "../ThemeColorProvider"; import FooterVisibilityContext from "../FooterVisibilityContext"; -import useGetSiteSettings from "../models/useGetSiteSettings"; +import { useSiteSettings } from "../SiteSettingsContext"; import { footerLinks1, footerLinks2, @@ -12,10 +12,10 @@ import { } from "../constants/footerMenu"; const Footer = () => { + const [version, setVersion] = useState(null); const theme = useContext(ThemeColorContext); const { visible } = useContext(FooterVisibilityContext); - const [version, setVersion] = useState(); - const { data } = useGetSiteSettings(); + const { data } = useSiteSettings(); useEffect(() => { if (data) { setVersion(data.system?.wildbookVersion); diff --git a/frontend/src/components/ImageModal.jsx b/frontend/src/components/ImageModal.jsx index 85a8050a88..5c032a71ff 100644 --- a/frontend/src/components/ImageModal.jsx +++ b/frontend/src/components/ImageModal.jsx @@ -1041,7 +1041,7 @@ export const ImageModal = observer( (a) => a.id === imageStore.selectedAnnotationId, )?.[0]?.iaTaskId; window.open( - `/iaResults.jsp?taskId=${encodeURIComponent(taskId)}`, + `/react/match-results?taskId=${taskId}`, "_blank", ); }} diff --git a/frontend/src/components/Map.jsx b/frontend/src/components/Map.jsx index a978dcdeb8..ac40c6296d 100644 --- a/frontend/src/components/Map.jsx +++ b/frontend/src/components/Map.jsx @@ -3,7 +3,7 @@ import GoogleMapReact from "google-map-react"; import BrutalismButton from "./BrutalismButton"; import ThemeContext from "../ThemeColorProvider"; import { FormattedMessage } from "react-intl"; -import useGetSiteSettings from "../models/useGetSiteSettings"; +import { useSiteSettings } from "../SiteSettingsContext"; const MapComponent = ({ setBounds, setTempBounds = () => {} }) => { const theme = useContext(ThemeContext); @@ -11,7 +11,7 @@ const MapComponent = ({ setBounds, setTempBounds = () => {} }) => { const drawingRef = useRef(false); const [isDrawing, setIsDrawing] = useState(false); - const { data } = useGetSiteSettings(); + const { data } = useSiteSettings(); const key = data?.googleMapsKey; const center = { lat: data?.mapCenterLat || 0, diff --git a/frontend/src/components/MultiSelectWithCheckbox.jsx b/frontend/src/components/MultiSelectWithCheckbox.jsx new file mode 100644 index 0000000000..b4567df2ab --- /dev/null +++ b/frontend/src/components/MultiSelectWithCheckbox.jsx @@ -0,0 +1,96 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Select, Button, Divider, Checkbox } from "antd"; + +const footerStyle = { + padding: 8, + display: "flex", + justifyContent: "flex-end", + alignItems: "center", +}; + +const optionRowStyle = { + display: "flex", + alignItems: "center", + gap: 8, +}; + +export default function MultiSelectWithCheckbox({ + options, + value, + onChangeCommitted, + placeholder = "Select...", + style, + disabled, +}) { + const selectRef = useRef(null); + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(value ?? []); + + useEffect(() => { + setDraft(value ?? []); + }, [value]); + + const draftSet = useMemo(() => new Set(draft ?? []), [draft]); + + const handleDone = () => { + onChangeCommitted?.(draft); + setOpen(false); + selectRef.current?.blur?.(); + }; + + return ( +
- + diff --git a/frontend/src/pages/BulkImport/EditableDataTable.jsx b/frontend/src/pages/BulkImport/EditableDataTable.jsx index 9b1f29b78b..a5e1027554 100644 --- a/frontend/src/pages/BulkImport/EditableDataTable.jsx +++ b/frontend/src/pages/BulkImport/EditableDataTable.jsx @@ -13,7 +13,7 @@ import { flexRender, } from "@tanstack/react-table"; import { observer } from "mobx-react-lite"; -import useGetSiteSettings from "../../models/useGetSiteSettings"; +import { useSiteSettings } from "../../SiteSettingsContext"; import SelectCell from "../../components/SelectCell"; import BulkImportApplyToAllRowsModal from "./BulkImportApplyToAllRowsModal"; @@ -168,7 +168,7 @@ const EditableCell = observer( export const DataTable = observer(({ store }) => { const data = store.spreadsheetData || []; const columnsDef = store.columnsDef || []; - const { data: siteData } = useGetSiteSettings(); + const { data: siteData } = useSiteSettings(); const minimalFields = siteData?.bulkImportMinimalFields || {}; const validLocationIDs = siteData?.locationData.locationID || []; const validSubmitterIDs = siteData?.users?.map((user) => user.username) || []; diff --git a/frontend/src/pages/EditAnnotation.jsx b/frontend/src/pages/EditAnnotation.jsx index ec750cc627..66ee542fbf 100644 --- a/frontend/src/pages/EditAnnotation.jsx +++ b/frontend/src/pages/EditAnnotation.jsx @@ -6,7 +6,7 @@ import Container from "react-bootstrap/Container"; import MainButton from "../components/MainButton"; import ThemeColorContext from "../ThemeColorProvider"; import ResizableRotatableRect from "../components/ResizableRotatableRect"; -import useGetSiteSettings from "../models/useGetSiteSettings"; +import { useSiteSettings } from "../SiteSettingsContext"; import { useSearchParams } from "react-router-dom"; import AnnotationSuccessful from "../components/AnnotationSuccessful"; import useCreateAnnotation from "../models/encounters/useCreateAnnotation"; @@ -64,7 +64,7 @@ export default function EditAnnotation() { height: 0, rotation: 0, }); - const { data: siteData } = useGetSiteSettings(); + const { data: siteData } = useSiteSettings(); const [rotationInfo, setRotationInfo] = useState(null); const [imageReady, setImageReady] = useState(false); diff --git a/frontend/src/pages/Encounter/Encounter.jsx b/frontend/src/pages/Encounter/Encounter.jsx index 70898348c0..232285b7ef 100644 --- a/frontend/src/pages/Encounter/Encounter.jsx +++ b/frontend/src/pages/Encounter/Encounter.jsx @@ -12,7 +12,7 @@ import LocationIcon from "../../components/icons/LocationIcon"; import AttributesIcon from "../../components/icons/AttributesIcon"; import ImageCard from "./ImageCard"; import CardWithEditButton from "../../components/CardWithEditButton"; -import useGetSiteSettings from "../../models/useGetSiteSettings"; +import { useSiteSettings } from "../../SiteSettingsContext"; import PillWithDropdown from "../../components/PillWithDropdown"; import ContactIcon from "../../components/icons/ContactIcon"; import HistoryIcon from "../../components/icons/HistoryIcon"; @@ -41,8 +41,8 @@ import Alert from "react-bootstrap/Alert"; const Encounter = observer(() => { const [store] = useState(() => new EncounterStore()); - const { data: siteSettings, loading: siteSettingsLoading } = - useGetSiteSettings(); + const { data: siteSettings, isLoading: siteSettingsLoading } = + useSiteSettings(); const [encounterValid, setEncounterValid] = useState(true); const [encounterDeleted, setEncounterDeleted] = useState(false); const intl = useIntl(); diff --git a/frontend/src/pages/Encounter/ImageCard.jsx b/frontend/src/pages/Encounter/ImageCard.jsx index 522fbb7255..7a85be934a 100644 --- a/frontend/src/pages/Encounter/ImageCard.jsx +++ b/frontend/src/pages/Encounter/ImageCard.jsx @@ -709,7 +709,7 @@ const ImageCard = observer(({ store = {} }) => { onClick={async () => { if (store.matchResultClickable) { const taskId = currentAnnotation?.iaTaskId; - const url = `/iaResults.jsp?taskId=${encodeURIComponent(taskId)}`; + const url = `/react/match-results?taskId=${encodeURIComponent(taskId)}`; window.open(url, "_blank", "noopener,noreferrer"); } else if ( clickedAnnotation && @@ -747,7 +747,7 @@ const ImageCard = observer(({ store = {} }) => { identActive && (detectionComplete || identificationStatus) ) { - const url = `/iaResults.jsp?taskId=${encodeURIComponent(selectedAnnotation.iaTaskId)}`; + const url = `/react/match-results?taskId=${encodeURIComponent(selectedAnnotation.iaTaskId)}`; window.open(url, "_blank", "noopener,noreferrer"); } else { alert("No match results available for this annotation."); diff --git a/frontend/src/pages/Encounter/MapDisplay.jsx b/frontend/src/pages/Encounter/MapDisplay.jsx index 5037518eac..e02590b36f 100644 --- a/frontend/src/pages/Encounter/MapDisplay.jsx +++ b/frontend/src/pages/Encounter/MapDisplay.jsx @@ -1,11 +1,11 @@ import React, { useEffect, useRef } from "react"; import { observer } from "mobx-react-lite"; import { Loader } from "@googlemaps/js-api-loader"; -import useGetSiteSettings from "../../models/useGetSiteSettings"; +import { useSiteSettings } from "../../SiteSettingsContext"; export const MapDisplay = observer(({ store, zoom = 4, disableUI = true }) => { const mapElRef = useRef(null); - const { data } = useGetSiteSettings(); + const { data } = useSiteSettings(); const apiKey = data?.googleMapsKey; const defaultCenter = { lat: data?.mapCenterLat, lng: data?.mapCenterLon }; diff --git a/frontend/src/pages/Encounter/MatchCriteria.jsx b/frontend/src/pages/Encounter/MatchCriteria.jsx index 831e26d9c1..a2bc4b4fe5 100644 --- a/frontend/src/pages/Encounter/MatchCriteria.jsx +++ b/frontend/src/pages/Encounter/MatchCriteria.jsx @@ -16,7 +16,10 @@ export const MatchCriteriaModal = observer(function MatchCriteriaModal({ onClose = () => {}, }) { const theme = React.useContext(ThemeColorContext); - const siteSettingsLoading = Boolean(store?.siteSettingsLoading); + + const algorithms = store?.newMatch?.algorithmOptions ?? []; + const selectedAlgorithms = store?.newMatch?.algorithms ?? []; + const locationIdOptions = store?.locationIdOptions ?? []; return (

- + {store?.siteSettingsLoading ? ( + +
Loading locations...
+
+ ) : ( Loading location picker...}> { - if (siteSettingsLoading) return; store?.newMatch?.handleStrictChange?.(vals, labels, extra); }} /> -
+ )} - - store?.newMatch?.setOwner?.(v)} - disabled={siteSettingsLoading} - /> - +
+ {store?.siteSettingsLoading ? ( + +
Loading owner...
+
+ ) : ( + store?.newMatch?.setOwner?.(v)} + /> + )} +
- - ({ ...base, zIndex: 9999 }) }} + placeholder="Select algorithms" + noOptionsMessage={() => "No algorithms available"} + value={algorithms.filter((o) => + selectedAlgorithms.includes(o.value), + )} + onChange={(newValue) => { + store?.newMatch?.setAlgorithm?.( + (newValue ?? []).map((o) => o.value), + ); + }} + closeMenuOnSelect={false} + /> + )} +
{ - const result = await store?.newMatch?.buildNewMatchPayload?.(); - if (result?.status === 200) { - const url = `/iaResults.jsp?taskId=${result?.data?.taskId}`; + const result = await store?.newMatch?.buildNewMatchPayload(); + if (result.status === 200) { + const url = `/react/match-results?taskId=${result?.data?.taskId}`; window.open(url, "_blank"); store?.modals?.setOpenMatchCriteriaModal?.(false); } else { diff --git a/frontend/src/pages/Encounter/ProjectsCard.jsx b/frontend/src/pages/Encounter/ProjectsCard.jsx index 81efa0b17c..46bb67a527 100644 --- a/frontend/src/pages/Encounter/ProjectsCard.jsx +++ b/frontend/src/pages/Encounter/ProjectsCard.jsx @@ -15,7 +15,7 @@ export const ProjectsCard = observer(({ store = {} }) => { const allProjectsRaw = store.siteSettingsData?.projectsForUser || {}; const allProjects = Object.entries(allProjectsRaw).map(([key, value]) => ({ id: key, - name: value, + name: value?.name, })); const encounterProjects = store.encounterData?.projects || []; const currentEncounterProjects = allProjects.filter((project) => diff --git a/frontend/src/pages/Encounter/stores/EncounterStore.js b/frontend/src/pages/Encounter/stores/EncounterStore.js index 60e57a4744..bdc491626b 100644 --- a/frontend/src/pages/Encounter/stores/EncounterStore.js +++ b/frontend/src/pages/Encounter/stores/EncounterStore.js @@ -722,22 +722,19 @@ class EncounterStore { get locationIdOptions() { if (this._siteSettingsData?.locationData?.locationID) { - this._locationIdOptions = convertToTreeDataWithName( + return convertToTreeDataWithName( this._siteSettingsData.locationData.locationID, ); - return this._locationIdOptions; } return []; } get identificationRemarksOptions() { if (this._siteSettingsData?.identificationRemarks) { - this._identificationRemarksOptions = - this._siteSettingsData.identificationRemarks.map((data) => ({ - value: data, - label: data, - })); - return this._identificationRemarksOptions; + return this._siteSettingsData.identificationRemarks.map((data) => ({ + value: data, + label: data, + })); } return []; } diff --git a/frontend/src/pages/Encounter/stores/NewMatchStore.js b/frontend/src/pages/Encounter/stores/NewMatchStore.js index 684c6f5bc5..d6c6a8e68e 100644 --- a/frontend/src/pages/Encounter/stores/NewMatchStore.js +++ b/frontend/src/pages/Encounter/stores/NewMatchStore.js @@ -28,13 +28,34 @@ class NewMatchStore { { fireImmediately: true }, ); + reaction( + () => this.shouldHideVectorAlgorithms, + (shouldHide) => { + if (shouldHide) { + this._algorithms = this._algorithms.filter( + (value) => !String(value).toLowerCase().includes("vector"), + ); + } + }, + { fireImmediately: true }, + ); + // Auto-select default algorithms when iaConfig becomes available reaction( () => this.iaConfigBasedOnTaxonomy, (iaConfig) => { if (this._algorithms.length === 0 && iaConfig?.length > 0) { const defaults = iaConfig - .filter((d) => d.default === true) + .filter((d) => { + if (d.default !== true) return false; + + const isVector = String(d.description) + .toLowerCase() + .includes("vector"); + + if (this.shouldHideVectorAlgorithms && isVector) return false; + return true; + }) .map((d) => d.description); if (defaults.length > 0) { this._algorithms = defaults; @@ -94,21 +115,58 @@ class NewMatchStore { .map((d) => d.id); } + get shouldHideVectorAlgorithms() { + const encounterData = this.encounterStore?.encounterData; + const allMediaAssets = encounterData?.mediaAssets || []; + const mediaAsset = + allMediaAssets[this.encounterStore?.selectedImageIndex] || {}; + const annotations = mediaAsset?.annotations || []; + const encounterId = encounterData?.id || ""; + + const currentEncounterAnnotations = annotations.filter( + (annotation) => annotation?.encounterId === encounterId, + ); + + const hasEmbedding = currentEncounterAnnotations.some((annotation) => { + const embeddingCounts = annotation?.embeddingCounts; + + return ( + embeddingCounts && + typeof embeddingCounts === "object" && + !Array.isArray(embeddingCounts) && + Object.keys(embeddingCounts).length > 0 + ); + }); + + return !hasEmbedding; + } + get algorithmOptions() { - return ( + const options = this.iaConfigBasedOnTaxonomy?.map((d) => ({ label: d.description, value: d.description, - })) || [] + })) || []; + + if (!this.shouldHideVectorAlgorithms) return options; + + return options.filter( + (option) => !String(option?.value).toLowerCase().includes("vector"), ); } get matchingAlgorithms() { const selected = new Set(this._algorithms || []); return ( - this.iaConfigBasedOnTaxonomy?.filter((cfg) => - selected.has(cfg.description), - ) || [] + this.iaConfigBasedOnTaxonomy?.filter((cfg) => { + const isSelected = selected.has(cfg.description); + const isVector = String(cfg.description) + .toLowerCase() + .includes("vector"); + + if (this.shouldHideVectorAlgorithms && isVector) return false; + return isSelected; + }) || [] ); } diff --git a/frontend/src/pages/ManualAnnotation.jsx b/frontend/src/pages/ManualAnnotation.jsx index 2a14b2f480..2b0f49ea0a 100644 --- a/frontend/src/pages/ManualAnnotation.jsx +++ b/frontend/src/pages/ManualAnnotation.jsx @@ -6,7 +6,7 @@ import Container from "react-bootstrap/Container"; import MainButton from "../components/MainButton"; import ThemeColorContext from "../ThemeColorProvider"; import ResizableRotatableRect from "../components/ResizableRotatableRect"; -import useGetSiteSettings from "../models/useGetSiteSettings"; +import { useSiteSettings } from "../SiteSettingsContext"; import { useSearchParams } from "react-router-dom"; import AnnotationSuccessful from "../components/AnnotationSuccessful"; import useCreateAnnotation from "../models/encounters/useCreateAnnotation"; @@ -49,7 +49,7 @@ export default function ManualAnnotation() { height: 0, rotation: 0, }); - const { data: siteData } = useGetSiteSettings(); + const { data: siteData } = useSiteSettings(); const [rotationInfo, setRotationInfo] = useState(null); const iaClassesForTaxonomy = siteData?.iaClassesForTaxonomy || {}; diff --git a/frontend/src/pages/MatchResultsPage/MatchResults.jsx b/frontend/src/pages/MatchResultsPage/MatchResults.jsx new file mode 100644 index 0000000000..2f5eaaf317 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/MatchResults.jsx @@ -0,0 +1,426 @@ +import React, { useMemo, useEffect } from "react"; +import { observer } from "mobx-react-lite"; +import { FormattedMessage } from "react-intl"; +import { Container, Form } from "react-bootstrap"; +import ThemeColorContext from "../../ThemeColorProvider"; +import MatchResultsStore from "./stores/matchResultsStore"; +import MatchProspectTable from "./components/MatchProspectTable"; +import MatchResultsBottomBar from "./components/MatchResultsBottomBar"; +import { useSearchParams } from "react-router-dom"; +import { useSiteSettings } from "../../SiteSettingsContext"; +import FullScreenLoader from "../../components/FullScreenLoader"; +import InstructionsModal from "./components/InstructionsModal"; +import InfoIcon from "./icons/InfoIcon"; +import FilterIcon from "./icons/FilterIcon"; +import MatchCriteriaDrawer from "./components/MatchCriteriaDrawer"; +import MultiSelectWithCheckbox from "../../components/MultiSelectWithCheckbox"; +import ContainerWithSpinner from "../../components/ContainerWithSpinner"; + +const MatchResults = observer(() => { + const themeColor = React.useContext(ThemeColorContext); + const store = useMemo(() => new MatchResultsStore(), []); + const [instructionsVisible, setInstructionsVisible] = React.useState(false); + const [params, setParams] = useSearchParams(); + const taskId = params.get("taskId"); + const projectIdPrefix = params.get("projectIdPrefix"); + const { data, isLoading: siteSettingsLoading } = useSiteSettings(); + + // Stabilize projectsForUser reference to prevent unnecessary effect re-renders + const projectsForUser = React.useMemo( + () => data?.projectsForUser ?? {}, + [data?.projectsForUser], + ); + const identificationRemarks = React.useMemo( + () => data?.identificationRemarks ?? [], + [data?.identificationRemarks], + ); + + const [filterVisible, setFilterVisible] = React.useState(false); + const [isInputFocused, setIsInputFocused] = React.useState(false); + + const projectOptions = useMemo(() => { + return Object.entries(projectsForUser).map(([key, value]) => ({ + value: key, + label: value?.name || key, + })); + }, [projectsForUser]); + + useEffect(() => { + if (taskId) { + let initialProjectIds = []; + + if (projectIdPrefix) { + if (siteSettingsLoading) return; + + const match = Object.entries(projectsForUser).find( + ([, p]) => p?.prefix === projectIdPrefix, + ); + if (match) { + initialProjectIds = [match[0]]; + } + } + + store.setTaskId(taskId); + store.setProjectNames(initialProjectIds, { fetch: false }); + store.fetchMatchResults(); + } else { + store.setTaskId(null); + store.setProjectNames([], { fetch: false }); + store.clearResults(); + } + }, [taskId, projectIdPrefix, projectsForUser, siteSettingsLoading]); + + useEffect(() => { + if (!taskId || !store.shouldPoll) return; + + let cancelled = false; + + const scheduleNext = async () => { + if (cancelled) return; + + await store.fetchMatchResults({ silent: true }); + + if (!cancelled && store.shouldPoll) { + setTimeout(scheduleNext, 5000); + } + }; + + scheduleNext(); + + return () => { + cancelled = true; + }; + }, [taskId, store.shouldPoll]); + + if (store.loading) { + return ; + } + + const showEmptyState = !store.hasDisplaySections; + + return ( + + setInstructionsVisible(false)} + taskId={taskId} + themeColor={themeColor} + /> + + setFilterVisible(false)} + filter={store.matchingSetFilter} + /> + + {store.hasResults && store.encounterId && ( +
+ +
+ )} + + {store.hasResults && store.encounterId && ( +
+ )} + +
+
+

+ +

+
+ + +
+ setInstructionsVisible(true)} + data-testid="match-results-instructions-icon" + /> +
+
+
+ +
+
+ + + +
+ +
+ + + + + +
+ { + const val = e.target.value; + if (/^\d*$/.test(val)) { + store.setNumResults(val === "" ? 1 : Number(val)); + } + }} + onFocus={() => setIsInputFocused(true)} + onBlur={() => setIsInputFocused(false)} + onKeyDown={(e) => { + if (e.key === "Enter") { + store.fetchMatchResults(); + } + }} + style={{ + width: "100%", + paddingRight: "30px", + }} + /> + + {isInputFocused && ( + + )} +
+
+ + + + + + +
+ + + } + onChangeCommitted={(projectIds) => { + store.setProjectNames(projectIds); + + if (!projectIds || projectIds.length === 0) { + const next = new URLSearchParams(params); + next.delete("projectIdPrefix"); + setParams(next, { replace: true }); + } + }} + style={{ width: "100%" }} + /> + +
+
+ +
setFilterVisible(true)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") setFilterVisible(true); + }} + aria-label="Open match criteria" + > + +
+
+
+ +
+ {showEmptyState ? ( +

+ +

+ ) : ( +
+ {(store.currentViewData || []).map( + ({ taskId, columns, metadata }) => ( +
+ { + store.setSelectedMatch( + checked, + key, + encounterId, + individualId, + individualDisplayName, + ); + }} + /> +
+ ), + )} +
+ )} +
+ + ); +}); + +export default MatchResults; diff --git a/frontend/src/pages/MatchResultsPage/components/CreateNewIndividualModal.jsx b/frontend/src/pages/MatchResultsPage/components/CreateNewIndividualModal.jsx new file mode 100644 index 0000000000..b5f5b0062a --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/components/CreateNewIndividualModal.jsx @@ -0,0 +1,296 @@ +import React from "react"; +import { Modal, Form, Button, Spinner } from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; +import { toast } from "react-toastify"; + +const CreateNewIndividualModal = ({ + show, + onHide, + encounterId, + newIndividualName, + onNameChange, + onConfirm, + loading, + themeColor, + identificationRemarks = [], + locationId = "", +}) => { + const intl = useIntl(); + const [selectedRemark, setSelectedRemark] = React.useState(""); + const [suggestedId, setSuggestedId] = React.useState(null); + const [loadingSuggestedId, setLoadingSuggestedId] = React.useState(false); + + React.useEffect(() => { + if (!show || !locationId) return undefined; + + const controller = new AbortController(); + setLoadingSuggestedId(true); + + fetch( + `/api/v3/individuals/info/next_name?locationId=${encodeURIComponent(locationId)}`, + { signal: controller.signal }, + ) + .then(async (res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); + }) + .then((data) => { + if (data.success === true && data.results && data.results.length > 0) { + const successfulResult = data.results.find((r) => r.success === true); + if (successfulResult && successfulResult.nextName) { + setSuggestedId(successfulResult.nextName); + } else { + setSuggestedId(null); + } + } else { + setSuggestedId(null); + } + }) + .catch((err) => { + if (err.name === "AbortError") return; + + setSuggestedId(null); + toast.error( + intl.formatMessage({ + id: "LOAD_SUGGESTED_ID_FAILED", + defaultMessage: "Failed to load suggested ID", + }), + ); + }) + .finally(() => { + if (!controller.signal.aborted) { + setLoadingSuggestedId(false); + } + }); + + return () => { + controller.abort(); + }; + }, [show, locationId, intl]); + + React.useEffect(() => { + if (!show) { + setSelectedRemark(""); + setSuggestedId(null); + } + }, [show]); + + const handleConfirm = () => { + onConfirm(selectedRemark); + }; + + const handleUseSuggestedId = () => { + onNameChange(suggestedId.toString(), true); + toast.success( + intl.formatMessage({ + id: "SUGGESTED_ID_APPLIED", + defaultMessage: "Suggested ID applied", + }), + ); + }; + + return ( + + + + + + + + +

+ {" "} + + {encounterId} + +

+ +
+ + + + + + setSelectedRemark(e.target.value)} + > + + {identificationRemarks.map((remark, index) => ( + + ))} + + + + + + + + + onNameChange(e.target.value, false)} + placeholder="Enter name" + /> + + + {locationId && ( +
+ {loadingSuggestedId ? ( + + + + + ) : suggestedId !== null && suggestedId !== undefined ? ( + <> + + + : {suggestedId} + {" "} + + + ) : null} +
+ )} +
+
+ + + + + + +
+ ); +}; + +export default CreateNewIndividualModal; diff --git a/frontend/src/pages/MatchResultsPage/components/EmptyMatchPlaceholder.jsx b/frontend/src/pages/MatchResultsPage/components/EmptyMatchPlaceholder.jsx new file mode 100644 index 0000000000..301ab25386 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/components/EmptyMatchPlaceholder.jsx @@ -0,0 +1,79 @@ +import React from "react"; + +const EmptyMatchPlaceholder = ({ sectionId }) => ( +
+ + + + + + + + + + + + + + + + + + +
+); + +export default EmptyMatchPlaceholder; diff --git a/frontend/src/pages/MatchResultsPage/components/InspectorModal.jsx b/frontend/src/pages/MatchResultsPage/components/InspectorModal.jsx new file mode 100644 index 0000000000..f2ddfcadde --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/components/InspectorModal.jsx @@ -0,0 +1,187 @@ +import React from "react"; +import { Modal } from "react-bootstrap"; +import ZoomInIcon from "../icons/ZoomInIcon"; +import ZoomOutIcon from "../icons/ZoomOutIcon"; +import InteractiveAnnotationOverlay from "../../../components/AnnotationOverlay"; + +const styles = { + body: { + padding: 12, + background: "#111", + height: "100vh", + }, + grid: { + height: "calc(100vh - 24px)", + display: "flex", + gap: 12, + }, + panel: { + flex: 1, + minWidth: 0, + borderRadius: 10, + overflow: "hidden", + background: "#1a1a1a", + position: "relative", + boxShadow: "0 2px 14px rgba(0,0,0,0.35)", + }, + imageWrap: { + position: "relative", + width: "100%", + height: "100%", + display: "flex", + alignItems: "center", + justifyContent: "center", + background: "#111", + }, + topRight: { + position: "absolute", + top: 10, + right: 10, + zIndex: 80, + display: "flex", + gap: 8, + }, + iconBtn: { + width: 34, + height: 34, + borderRadius: 10, + background: "rgba(255,255,255,0.92)", + border: "1px solid rgba(0,0,0,0.10)", + display: "flex", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", + boxShadow: "0 2px 10px rgba(0,0,0,0.25)", + }, + overlayHost: { + width: "100%", + height: "100%", + }, +}; + +const CloseIcon = () => ( + + + +); + +export default function InspectorModal({ + show, + onHide, + imageUrl, + originalWidth, + originalHeight, +}) { + const overlayRef = React.useRef(null); + + React.useEffect(() => { + if (!show) return; + const t = setTimeout(() => overlayRef.current?.reset?.(), 0); + return () => clearTimeout(t); + }, [show, imageUrl]); + + return ( + +
+
+
+
+
+
overlayRef.current?.zoomIn?.()} + id="inspector-modal-zoom-in" + data-testid="inspector-modal-zoom-in" + > + +
+ +
overlayRef.current?.zoomOut?.()} + id="inspector-modal-zoom-out" + data-testid="inspector-modal-zoom-out" + > + +
+ +
+ +
+
+ +
+ +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/MatchResultsPage/components/InstructionsModal.jsx b/frontend/src/pages/MatchResultsPage/components/InstructionsModal.jsx new file mode 100644 index 0000000000..c1db612514 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/components/InstructionsModal.jsx @@ -0,0 +1,284 @@ +import React from "react"; +import { Modal } from "react-bootstrap"; +import { FormattedMessage } from "react-intl"; +import ZoomInIcon from "../icons/ZoomInIcon"; +import ZoomOutIcon from "../icons/ZoomOutIcon"; +import ToggleAnnotationIcon from "../icons/ToggleAnnotationIcon"; +import HatchMarkIcon from "../icons/HatchMarkIcon"; +import FullScreenIcon from "../icons/FullScreenIcon"; + +const SectionTitle = ({ id, testId }) => ( +
+ +
+); + +const BulletList = ({ items, testIdPrefix }) => ( +
    + {items.map((id, idx) => ( +
  • + +
  • + ))} +
+); + +export default function InstructionsModal({ + show, + onHide, + taskId, + themeColor, +}) { + const primary = themeColor?.primaryColors?.primary500 || "#0d6efd"; + const [copied, setCopied] = React.useState(false); + + const handleCopy = async () => { + if (!taskId) return; + try { + await navigator.clipboard.writeText(taskId); + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + } catch (e) { + console.error(e); + } + }; + + return ( + + + + + + + + +
+
+ + : + + + + {taskId || "-"} + + + +
+
+ + +
+ +
+ + + Match Result Example + + +
+ +
+ + + + + + + + +
+
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+ + +
+
+ ); +} diff --git a/frontend/src/pages/MatchResultsPage/components/MatchConfirmedModal.jsx b/frontend/src/pages/MatchResultsPage/components/MatchConfirmedModal.jsx new file mode 100644 index 0000000000..f57a630856 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/components/MatchConfirmedModal.jsx @@ -0,0 +1,110 @@ +import React from "react"; +import { Modal, Button } from "react-bootstrap"; +import { FormattedMessage } from "react-intl"; + +const MatchConfirmedModal = ({ + show, + onHide, + encounterId, + encounterCount, + individualId, + individualName, + themeColor, +}) => { + const handleClose = () => { + onHide(); + window.location.reload(); + }; + + return ( + + + + + + + + +

+ {encounterCount > 0 ? ( + <> + {" "} + + ) : ( + <> + {" "} + + {encounterId} + {" "} + {" "} + + )} + + {individualName || individualId} + +

+
+ + + + +
+ ); +}; + +export default MatchConfirmedModal; diff --git a/frontend/src/pages/MatchResultsPage/components/MatchCriteriaDrawer.jsx b/frontend/src/pages/MatchResultsPage/components/MatchCriteriaDrawer.jsx new file mode 100644 index 0000000000..0a1221c693 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/components/MatchCriteriaDrawer.jsx @@ -0,0 +1,48 @@ +import React from "react"; +import { Offcanvas } from "react-bootstrap"; +import { FormattedMessage } from "react-intl"; + +export default function MatchCriteriaDrawer({ show, onHide, filter }) { + return ( + + + + + + + +
+ { +
+ +
+ } + {filter?.locationIds && filter?.locationIds.length > 0 && ( +
+ :{" "} + {filter?.locationIds?.join(", ")} +
+ )} + {filter?.owner && ( +
+ : {filter?.owner} +
+ )} + {!filter?.owner && !filter?.locationIds && ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx new file mode 100644 index 0000000000..b0c82fa0d7 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/components/MatchProspectTable.jsx @@ -0,0 +1,1086 @@ +import React, { useRef, useState } from "react"; +import { Row, Col, Form, Modal, Spinner } from "react-bootstrap"; +import ZoomInIcon from "../icons/ZoomInIcon"; +import ZoomOutIcon from "../icons/ZoomOutIcon"; +import HatchMarkIcon from "../icons/HatchMarkIcon"; +import ToggleAnnotationIcon from "../icons/ToggleAnnotationIcon"; +import FullScreenIcon from "../icons/FullScreenIcon"; +import InteractiveAnnotationOverlay from "../../../components/AnnotationOverlay"; +import { FormattedMessage, useIntl } from "react-intl"; +import InspectorModal from "./InspectorModal"; +import ExitFullScreenIcon from "../icons/ExitFullScreenIcon"; +import EncounterIcon from "../../../components/icons/EncounterIcon"; +import EmptyMatchPlaceholder from "./EmptyMatchPlaceholder"; + +const styles = { + matchRow: (selected, themeColor) => ({ + display: "flex", + alignItems: "center", + gap: "8px", + padding: "6px 10px", + fontSize: "1rem", + marginTop: "4px", + borderRadius: "5px", + backgroundColor: selected + ? themeColor.primaryColors.primary50 + : "transparent", + }), + matchRank: { + width: "24px", + textAlign: "right", + marginRight: "8px", + }, + idPill: (themeColor) => ({ + borderRadius: "5px", + border: "none", + padding: "2px 10px", + fontSize: "1rem", + background: themeColor.wildMeColors.teal100, + color: themeColor.wildMeColors.teal800, + maxWidth: "200px", + overflow: "hidden", + }), + encounterButton: () => ({ + borderRadius: "50%", + border: "none", + fontSize: "1rem", + display: "flex", + alignItems: "center", + gap: "4px", + width: "20px", + height: "20px", + padding: 0, + lineHeight: 0, + }), + matchImageCard: { + position: "relative", + borderRadius: "8px", + boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)", + overflow: "hidden", + }, + imageContainer: { + width: "100%", + height: "100%", + overflow: "hidden", + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: "#f8f9fa", + }, + cornerLabel: (themeColor) => ({ + position: "absolute", + top: "8px", + left: "-8px", + background: themeColor.wildMeColors.teal100, + color: themeColor.wildMeColors.teal800, + padding: "2px 8px", + borderRadius: "2px", + fontSize: "1rem", + zIndex: 10, + }), + toolsBarLeft: { + position: "absolute", + top: "0", + left: "-40px", + display: "flex", + flexDirection: "column", + gap: "6px", + }, + toolsBarRight: { + position: "absolute", + top: "0", + right: "-40px", + display: "flex", + flexDirection: "column", + gap: "6px", + }, + iconButton: { + width: "32px", + height: "32px", + borderRadius: "8px", + cursor: "pointer", + }, + iconButtonDisabled: { + width: "32px", + height: "32px", + borderRadius: "8px", + cursor: "not-allowed", + opacity: 0.4, + }, + matchListScrollContainer: { + overflowX: "auto", + overflowY: "hidden", + marginBottom: "1rem", + }, + matchListGrid: { + display: "flex", + gap: "12px", + width: "100%", + }, + matchColumn: { + flex: 1, + minWidth: "30%", + display: "flex", + flexDirection: "column", + }, + fullscreenBody: { + padding: 12, + background: "#111", + height: "100vh", + }, + fullscreenGrid: { + height: "calc(100vh - 24px)", + display: "flex", + gap: 12, + }, + fullscreenPanel: { + flex: 1, + minWidth: 0, + borderRadius: 10, + overflow: "hidden", + background: "#1a1a1a", + position: "relative", + boxShadow: "0 2px 14px rgba(0,0,0,0.35)", + }, + fullscreenLabel: { + position: "absolute", + top: 10, + left: 10, + zIndex: 5, + background: "rgba(255,255,255,0.92)", + padding: "3px 10px", + borderRadius: 6, + fontSize: 12, + }, + fullscreenImageWrap: { + position: "relative", + width: "100%", + height: "100%", + display: "flex", + alignItems: "center", + justifyContent: "center", + background: "#111", + }, + fullscreenTopRight: { + position: "absolute", + top: 10, + right: 10, + zIndex: 80, + display: "flex", + gap: 8, + }, +}; + +const MatchProspectTable = ({ + sectionId, + numCandidates, + date, + selectedMatch, + onToggleSelected, + thisEncounterImageUrl, + thisEncounterAnnotations, + thisEncounterImageAsset, + themeColor, + columns = [], + algorithm, + methodName, + methodDescription, + taskStatusOverall, + emptyStateType, + errors, +}) => { + const intl = useIntl(); + const matchesBasedOnText = intl.formatMessage({ id: "MATCHED_BASED_ON" }); + const leftOverlayRef = useRef(null); + const rightOverlayRef = useRef(null); + + const [fullscreenOpen, setFullscreenOpen] = useState(false); + const fsLeftRef = useRef(null); + const fsRightRef = useRef(null); + + const hasProspects = columns.some((columnData) => + columnData.some((candidate) => candidate?.annotation), + ); + + const [previewedRow, setPreviewedRow] = useState(() => { + const first = + columns + .flatMap((columnData) => columnData) + .find((candidate) => candidate?.annotation) ?? null; + if (!first) return null; + const firstKey = `${first.annotation?.id}-${first.displayIndex}`; + return { ...first, _rowKey: firstKey }; + }); + + const [inspectorOpen, setInspectorOpen] = useState(false); + const inspectorUrl = previewedRow?.asset?.url; + const inspectorOrigW = previewedRow?.asset?.width; + const inspectorOrigH = previewedRow?.asset?.height; + + React.useEffect(() => { + const flat = columns.flatMap((columnData) => columnData); + const candidates = flat.filter((candidate) => candidate?.annotation); + + if (candidates.length === 0) { + setPreviewedRow(null); + return; + } + + setPreviewedRow((prev) => { + if (prev?.annotation?.id) { + const matched = candidates.find( + (candidate) => candidate?.annotation?.id === prev.annotation.id, + ); + + if (matched) { + const matchedKey = `${matched.annotation?.id}-${matched.displayIndex}`; + return { ...matched, _rowKey: matchedKey }; + } + } + + const first = candidates[0]; + const firstKey = `${first.annotation?.id}-${first.displayIndex}`; + return { ...first, _rowKey: firstKey }; + }); + }, [columns]); + + const [hoveredRow, setHoveredRow] = React.useState(null); + + const handleRowClick = (rowData, rowKey) => { + setPreviewedRow({ ...rowData, _rowKey: rowKey }); + rightOverlayRef.current?.reset?.(); + }; + + const isSelected = (rowKey) => selectedMatch?.some((d) => d.key === rowKey); + + const rightAnnotations = React.useMemo(() => { + const ann = previewedRow?.annotation; + if (!ann) return []; + return [ + { + id: ann.id, + boundingBox: ann.boundingBox, + x: ann.x, + y: ann.y, + width: ann.width, + height: ann.height, + theta: ann.theta, + trivial: ann.isTrivial || ann.trivial, + }, + ]; + }, [previewedRow]); + + const rightImageUrl = previewedRow?.annotation?.asset?.url; + + const leftOrigW = + thisEncounterImageAsset?.attributes?.width ?? + thisEncounterImageAsset?.width; + const leftOrigH = + thisEncounterImageAsset?.attributes?.height ?? + thisEncounterImageAsset?.height; + + const leftAnnotations = thisEncounterAnnotations; + const leftRotationInfo = thisEncounterImageAsset?.rotationInfo; + + const rightOrigW = + previewedRow?.annotation?.asset?.width ?? + previewedRow?.annotation?.asset?.attributes?.width; + const rightOrigH = + previewedRow?.annotation?.asset?.height ?? + previewedRow?.annotation?.asset?.attributes?.height; + + const leftImageUrl = thisEncounterImageUrl; + const hasLeftImage = Boolean(leftImageUrl); + const hasRightImage = Boolean(rightImageUrl); + + const openFullscreen = () => { + if (!hasRightImage) return; + setFullscreenOpen(true); + }; + + React.useEffect(() => { + if (!fullscreenOpen) return; + fsLeftRef.current?.reset?.(); + fsRightRef.current?.reset?.(); + }, [fullscreenOpen]); + + const isStillRunning = + !!taskStatusOverall && + taskStatusOverall !== "completed" && + taskStatusOverall !== "error"; + + const isError = taskStatusOverall === "error"; + + return ( +
+
+
+
+ {methodDescription + ? `${matchesBasedOnText} ${methodDescription}` + : methodName + ? `${matchesBasedOnText} ${methodName}` + : algorithm} +
+ +
+
+ {numCandidates}{" "} + {" "} +
+ +
+ {date?.slice(0, 16)?.replace("T", " ")} +
+
+
+
+ +
+ {hasProspects ? ( +
+ {columns.map((columnData, columnIndex) => ( +
+ {columnData + .filter((candidate) => candidate?.annotation) + .map((candidate) => { + const candidateEncounterId = + candidate.annotation?.encounter?.id; + const candidateIndividualId = + candidate.annotation?.individual?.id; + const candidateIndividualDisplayName = + candidate.annotation?.individual?.displayName; + + const canOpenEncounter = Boolean(candidateEncounterId); + const canOpenIndividual = Boolean(candidateIndividualId); + + const rowKey = `${candidate.annotation?.id ?? candidate.annotation?.encounter?.id ?? "no-annot"}-${candidate.displayIndex ?? "no-idx"}`; + const isRowSelected = isSelected(rowKey); + const isRowPreviewed = rowKey === previewedRow?._rowKey; + const isRowHovered = rowKey === hoveredRow; + + return ( +
handleRowClick(candidate, rowKey)} + style={{ + ...styles.matchRow(isRowSelected, themeColor), + cursor: "pointer", + backgroundColor: + isRowPreviewed || isRowHovered + ? themeColor.primaryColors.primary50 + : "transparent", + }} + onMouseEnter={() => setHoveredRow(rowKey)} + onMouseLeave={() => setHoveredRow(null)} + > + + {candidate.displayIndex}. + + + { + e.stopPropagation(); + if (!canOpenEncounter) e.preventDefault(); + }} + > + {Number.isFinite(candidate?.score) + ? Math.max(candidate.score, 0).toLocaleString( + undefined, + { maximumFractionDigits: 4 }, + ) + : "—"} + + + + + {(isRowHovered || isRowSelected) && ( + + )} + +
+ +
e.stopPropagation()} + data-testid={`match-prospect-actions-${sectionId}-${rowKey}`} + > + + onToggleSelected( + e.target.checked, + rowKey, + candidateEncounterId, + candidateIndividualId, + candidateIndividualDisplayName, + ) + } + /> +
+
+ ); + })} +
+ ))} +
+ ) : ( +
+ {isStillRunning ? ( +
+ + +
+ ) : isError ? ( +
+
+ +
+ + {Array.isArray(errors) && errors.length > 0 && ( + <> +
+ +
+ +
    + {errors.map((err, index) => ( +
  • + +
  • + ))} +
+ + )} +
+ ) : ( +
+ {emptyStateType === "no_candidates" || + emptyStateType === "no_prospects" ? ( + + ) : ( + + )} +
+ )} +
+ )} +
+ + + +
+
+ +
+
+ {hasLeftImage ? ( + + ) : ( + + )} +
+
+ +
+
leftOverlayRef.current?.zoomIn?.()} + style={ + hasLeftImage ? styles.iconButton : styles.iconButtonDisabled + } + title="Zoom In" + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + leftOverlayRef.current?.zoomIn?.(); + } + }} + id={`match-prospect-left-zoom-in-${sectionId}`} + data-testid={`match-prospect-left-zoom-in-${sectionId}`} + aria-disabled={!hasLeftImage} + > + +
+ +
leftOverlayRef.current?.zoomOut?.()} + style={ + hasLeftImage ? styles.iconButton : styles.iconButtonDisabled + } + title="Zoom Out" + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + leftOverlayRef.current?.zoomOut?.(); + } + }} + id={`match-prospect-left-zoom-out-${sectionId}`} + data-testid={`match-prospect-left-zoom-out-${sectionId}`} + aria-disabled={!hasLeftImage} + > + +
+
+ + + +
+
+ +
+
+ {hasRightImage ? ( + + ) : ( + + )} +
+
+ +
+
{ + if (!hasRightImage) return; + rightOverlayRef.current?.zoomIn?.(); + }} + style={ + hasRightImage ? styles.iconButton : styles.iconButtonDisabled + } + title="Zoom In" + role="button" + tabIndex={hasRightImage ? 0 : -1} + onKeyDown={(e) => { + if (!hasRightImage) return; + if (e.key === "Enter" || e.key === " ") { + rightOverlayRef.current?.zoomIn?.(); + } + }} + id={`match-prospect-right-zoom-in-${sectionId}`} + data-testid={`match-prospect-right-zoom-in-${sectionId}`} + aria-disabled={!hasRightImage} + > + +
+ +
{ + if (!hasRightImage) return; + rightOverlayRef.current?.zoomOut?.(); + }} + style={ + hasRightImage ? styles.iconButton : styles.iconButtonDisabled + } + title="Zoom Out" + role="button" + tabIndex={hasRightImage ? 0 : -1} + onKeyDown={(e) => { + if (!hasRightImage) return; + if (e.key === "Enter" || e.key === " ") { + rightOverlayRef.current?.zoomOut?.(); + } + }} + id={`match-prospect-right-zoom-out-${sectionId}`} + data-testid={`match-prospect-right-zoom-out-${sectionId}`} + aria-disabled={!hasRightImage} + > + +
+ +
{ + if (!inspectorUrl || !hasRightImage) return; + if (e.key === "Enter" || e.key === " ") { + setInspectorOpen(true); + } + }} + onClick={() => { + if (inspectorUrl && hasRightImage) setInspectorOpen(true); + }} + id={`match-prospect-inspector-open-${sectionId}`} + data-testid={`match-prospect-inspector-open-${sectionId}`} + aria-disabled={!inspectorUrl || !hasRightImage} + > + +
+ +
{ + if (!hasRightImage) return; + if (e.key === "Enter" || e.key === " ") { + rightOverlayRef.current?.toggleAnnotations?.(); + leftOverlayRef.current?.toggleAnnotations?.(); + } + }} + onClick={() => { + if (!hasRightImage) return; + rightOverlayRef.current?.toggleAnnotations?.(); + leftOverlayRef.current?.toggleAnnotations?.(); + }} + id={`match-prospect-toggle-annotations-${sectionId}`} + data-testid={`match-prospect-toggle-annotations-${sectionId}`} + aria-disabled={!hasRightImage} + > + +
+ +
{ + if (e.key === "Enter" || e.key === " ") { + if (!hasRightImage) return; + openFullscreen(); + } + }} + onClick={(e) => { + e.stopPropagation(); + if (!hasRightImage) return; + openFullscreen(); + }} + id={`match-prospect-fullscreen-open-${sectionId}`} + data-testid={`match-prospect-fullscreen-open-${sectionId}`} + aria-disabled={!hasRightImage} + > + +
+
+ +
+ + {hasProspects && hasRightImage && ( + setFullscreenOpen(false)} + fullscreen + centered={false} + keyboard + contentClassName="border-0 rounded-0" + data-testid={`match-prospect-fullscreen-modal-${sectionId}`} + > +
+
+
+
+
+ +
+ +
+
{ + if (e.key === "Enter" || e.key === " ") { + fsLeftRef.current?.zoomIn?.(); + } + }} + onClick={() => fsLeftRef.current?.zoomIn?.()} + id={`match-prospect-fullscreen-left-zoom-in-${sectionId}`} + data-testid={`match-prospect-fullscreen-left-zoom-in-${sectionId}`} + > + +
+
{ + if (e.key === "Enter" || e.key === " ") { + fsLeftRef.current?.zoomOut?.(); + } + }} + onClick={() => fsLeftRef.current?.zoomOut?.()} + id={`match-prospect-fullscreen-left-zoom-out-${sectionId}`} + data-testid={`match-prospect-fullscreen-left-zoom-out-${sectionId}`} + > + +
+
+ + +
+
+ +
+
+
+ +
+ +
+
{ + if (e.key === "Enter" || e.key === " ") { + fsRightRef.current?.zoomIn?.(); + } + }} + onClick={() => fsRightRef.current?.zoomIn?.()} + id={`match-prospect-fullscreen-right-zoom-in-${sectionId}`} + data-testid={`match-prospect-fullscreen-right-zoom-in-${sectionId}`} + > + +
+
{ + if (e.key === "Enter" || e.key === " ") { + fsRightRef.current?.zoomOut?.(); + } + }} + onClick={() => fsRightRef.current?.zoomOut?.()} + id={`match-prospect-fullscreen-right-zoom-out-${sectionId}`} + data-testid={`match-prospect-fullscreen-right-zoom-out-${sectionId}`} + > + +
+ +
{ + if (!inspectorUrl) return; + if (e.key === "Enter" || e.key === " ") + setInspectorOpen(true); + }} + onClick={() => { + if (inspectorUrl) setInspectorOpen(true); + }} + id={`match-prospect-fullscreen-inspector-open-${sectionId}`} + data-testid={`match-prospect-fullscreen-inspector-open-${sectionId}`} + aria-disabled={!inspectorUrl} + > + +
+ +
{ + if (e.key === "Enter" || e.key === " ") { + fsRightRef.current?.toggleAnnotations?.(); + fsLeftRef.current?.toggleAnnotations?.(); + } + }} + onClick={() => { + fsRightRef.current?.toggleAnnotations?.(); + fsLeftRef.current?.toggleAnnotations?.(); + }} + id={`match-prospect-fullscreen-toggle-annotations-${sectionId}`} + data-testid={`match-prospect-fullscreen-toggle-annotations-${sectionId}`} + > + +
+ +
{ + if (e.key === "Enter" || e.key === " ") { + setFullscreenOpen(false); + } + }} + onClick={() => setFullscreenOpen(false)} + id={`match-prospect-fullscreen-exit-${sectionId}`} + data-testid={`match-prospect-fullscreen-exit-${sectionId}`} + > + +
+
+ + +
+
+
+
+
+ )} + + setInspectorOpen(false)} + imageUrl={inspectorUrl} + originalWidth={inspectorOrigW} + originalHeight={inspectorOrigH} + /> +
+ ); +}; + +export default MatchProspectTable; diff --git a/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx b/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx new file mode 100644 index 0000000000..37411cb888 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/components/MatchResultsBottomBar.jsx @@ -0,0 +1,530 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +import { FormattedMessage } from "react-intl"; +import { Spinner } from "react-bootstrap"; +import MainButton from "../../../components/MainButton"; +import CreateNewIndividualModal from "./CreateNewIndividualModal"; +import NewIndividualCreatedModal from "./NewIndividualCreatedModal"; +import MatchConfirmedModal from "./MatchConfirmedModal"; + +const styles = { + bottomBar: (themeColor) => ({ + position: "fixed", + left: 0, + right: 0, + top: "50px", + background: themeColor.primaryColors.primary50, + borderTop: "1px solid #dee2e6", + padding: "10px 24px", + display: "flex", + gap: "24px", + zIndex: 20, + height: "60px", + }), + bottomText: { + fontSize: "1.2rem", + fontWeight: "500", + }, + warningText: { + color: "#dc3545", + fontSize: "1.2rem", + fontWeight: "500", + }, +}; + +const MatchResultsBottomBar = observer( + ({ store, themeColor, identificationRemarks }) => { + const matchingState = store.matchingState; + const [showCreateModal, setShowCreateModal] = useState(false); + const [showSuccessModal, setShowSuccessModal] = useState(false); + const [showMatchConfirmedModal, setShowMatchConfirmedModal] = + useState(false); + const [matchConfirmedData, setMatchConfirmedData] = useState(null); + + const handleCreateNewIndividual = async (selectedRemark) => { + const result = await store.handleCreateNewIndividual(selectedRemark); + + if (result?.ok) { + setShowCreateModal(false); + setShowSuccessModal(true); + } + }; + + const handleMatch = async () => { + const all = store.selectedIncludingQuery || []; + const individualItem = all.find((x) => x?.individualId); + + const encounterCount = all.filter( + (x) => x?.encounterId && !x?.individualId, + ).length; + + const modalData = { + encounterId: store.encounterId, + encounterCount, + individualId: individualItem?.individualId, + individualName: + individualItem?.individualDisplayName || individualItem?.individualId, + }; + + const result = await store.handleMatch(); + + if (result) { + setMatchConfirmedData(modalData); + setShowMatchConfirmedModal(true); + } + }; + + const handleMerge = async () => { + await store.handleMerge(); + }; + + const getActionContent = () => { + switch (matchingState) { + case "no_individuals": { + const encId = store.encounterId || ""; + const shortEncId = encId.slice(0, 5); + + const left = ( +
+ {" "} + {store.individualDisplayName ? ( + + {store.individualDisplayName} + + ) : ( + + {"encounter"} {shortEncId} + + )}{" "} + +
+ ); + + const right = ( + setShowCreateModal(true)} + disabled={store.matchRequestLoading} + style={{ marginTop: 0, marginBottom: 0 }} + id="match-bottombar-mark-new" + data-testid="match-bottombar-mark-new" + > + + {store.matchRequestLoading && ( + + ); + + return { left, right }; + } + + case "single_individual": { + const all = store.selectedIncludingQuery || []; + const individualItem = all.find((x) => x?.individualId); + + const individualName = + (individualItem?.encounterId === store.encounterId + ? store.individualDisplayName + : null) || + individualItem?.individualDisplayName || + individualItem?.individualId || + ""; + + const encounterNum = all.filter( + (x) => x?.encounterId && !x?.individualId, + ).length; + + return { + left: ( +
+ {" "} + + {individualName} + + {encounterNum > 0 && ( + <> + {" "} + + + )} +
+ ), + right: ( + + + {store.matchRequestLoading && ( + + ), + }; + } + + case "two_individuals": { + const all = store.selectedIncludingQuery || []; + const individualsRaw = all.filter((x) => x?.individualId); + const individuals = Array.from( + new Map(individualsRaw.map((x) => [x.individualId, x])).values(), + ); + + individuals.sort((x) => + x?.encounterId === store.encounterId ? -1 : 1, + ); + + const a = individuals[0]; + const b = individuals[1]; + + const nameA = (a?.encounterId === store.encounterId + ? store.individualDisplayName + : null) || + a?.individualDisplayName || + a?.individualId || ; + + const nameB = (b?.encounterId === store.encounterId + ? store.individualDisplayName + : null) || + b?.individualDisplayName || + b?.individualId || ; + + const encounters = all.filter( + (x) => x?.encounterId && !x?.individualId, + ); + + return { + left: ( +
+ {" "} + + {nameA} + {" "} + {" "} + + {nameB} + + {encounters.length > 0 && ( + <> + {" "} + + + )} +
+ ), + right: ( + + + {store.matchRequestLoading && ( + + ), + }; + } + + case "too_many_individuals": + return { + left: ( +
+ +
+ ), + right: null, + }; + + case "no_further_action_needed": + if (store.selectedMatch.length === 0) { + const encId = store.encounterId || ""; + const shortEncId = encId.slice(0, 5); + + return { + left: ( +
+ {" "} + {store.individualDisplayName ? ( + + {store.individualDisplayName} + + ) : ( + + {"encounter"} {shortEncId} + + )} +
+ ), + right: null, + }; + } + + return { + left: ( +
+ +
+ ), + right: null, + }; + + default: + return { left: null, right: null }; + } + }; + + const { left, right } = getActionContent(); + + return ( + <> +
+
+
+ {left} +
+ +
+ {right} + + window.close()} + style={{ marginTop: 0, marginBottom: 0 }} + id="match-results-bottom-bar-cancel" + data-testid="match-results-bottom-bar-cancel" + > + + +
+
+
+ + setShowCreateModal(false)} + encounterId={store.encounterId} + newIndividualName={store.newIndividualName} + onNameChange={store.setNewIndividualName} + onConfirm={handleCreateNewIndividual} + loading={store.matchRequestLoading} + themeColor={themeColor} + identificationRemarks={identificationRemarks} + locationId={store.encounterLocationId} + /> + + { + setShowSuccessModal(false); + window.location.reload(); + }} + encounterId={store.encounterId} + individualName={store.newIndividualName} + themeColor={themeColor} + /> + + {matchConfirmedData && ( + { + setShowMatchConfirmedModal(false); + setMatchConfirmedData(null); + }} + encounterId={matchConfirmedData.encounterId} + encounterCount={matchConfirmedData.encounterCount} + individualId={matchConfirmedData.individualId} + individualName={matchConfirmedData.individualName} + themeColor={themeColor} + /> + )} + + ); + }, +); + +export default MatchResultsBottomBar; diff --git a/frontend/src/pages/MatchResultsPage/components/NewIndividualCreatedModal.jsx b/frontend/src/pages/MatchResultsPage/components/NewIndividualCreatedModal.jsx new file mode 100644 index 0000000000..ff48e3be9e --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/components/NewIndividualCreatedModal.jsx @@ -0,0 +1,50 @@ +import React from "react"; +import { Modal, Button } from "react-bootstrap"; +import { FormattedMessage } from "react-intl"; + +const NewIndividualCreatedModal = ({ + show, + onHide, + encounterId, + individualName, + themeColor, +}) => { + return ( + + + + + + + + +

+ {" "} + + {encounterId} + {" "} + {individualName}. +

+
+ + + + +
+ ); +}; + +export default NewIndividualCreatedModal; diff --git a/frontend/src/pages/MatchResultsPage/constants.js b/frontend/src/pages/MatchResultsPage/constants.js new file mode 100644 index 0000000000..4ce2c684b3 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/constants.js @@ -0,0 +1,2 @@ + +export const MAX_ROWS_PER_COLUMN = 4; diff --git a/frontend/src/pages/MatchResultsPage/helperFunctions.js b/frontend/src/pages/MatchResultsPage/helperFunctions.js new file mode 100644 index 0000000000..2987343042 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/helperFunctions.js @@ -0,0 +1,154 @@ +const collectProspects = (node, type, result = []) => { + if (!node) return result; + + const hasMethod = !!node.method; + const taskCreated = !!node.statusOverall || hasMethod || !!node.dateCreated; + const methodName = node.method?.name ?? node.method?.description; + const methodDescription = node.method?.description ?? null; + + const prospects = node.matchResults?.prospects?.[type]; + + const safeProspects = Array.isArray(prospects) + ? prospects.filter((p) => p && typeof p === "object") + : []; + + const numberCandidatesRaw = node.matchResults?.numberCandidates; + const numberCandidates = + typeof numberCandidatesRaw === "number" ? numberCandidatesRaw : "-"; + + let emptyStateType = null; + + if (numberCandidates === 0) { + emptyStateType = "no_candidates"; + } else if (numberCandidates > 0 && safeProspects.length === 0) { + emptyStateType = "no_prospects"; + } + + const taskStatusOverall = node.statusOverall ?? null; + const nodeIsTerminal = isTerminalStatus(taskStatusOverall); + const nodeIsStillRunning = !!taskStatusOverall && !nodeIsTerminal; + + if (taskCreated) { + const common = { + algorithm: methodName, + date: node.dateCreated, + numberCandidates: numberCandidatesRaw ?? "-", + queryEncounterId: + node.matchResults?.queryAnnotation?.encounter?.id ?? null, + encounterLocationId: + node.matchResults?.queryAnnotation?.encounter?.locationId ?? null, + matchingSetFilter: node.matchingSetFilter, + queryIndividualId: + node.matchResults?.queryAnnotation?.individual?.id ?? null, + queryIndividualDisplayName: + node.matchResults?.queryAnnotation?.individual?.displayName ?? null, + queryEncounterImageAsset: + node.matchResults?.queryAnnotation?.asset ?? null, + queryEncounterImageUrl: + node.matchResults?.queryAnnotation?.asset?.url ?? null, + queryEncounterAnnotation: node.matchResults?.queryAnnotation + ? { + id: node.matchResults.queryAnnotation?.id, + x: node.matchResults.queryAnnotation?.x, + y: node.matchResults.queryAnnotation?.y, + width: node.matchResults.queryAnnotation?.width, + height: node.matchResults.queryAnnotation?.height, + theta: node.matchResults.queryAnnotation?.theta, + boundingBox: node.matchResults.queryAnnotation?.boundingBox, + isTrivial: node.matchResults.queryAnnotation?.isTrivial, + trivial: node.matchResults.queryAnnotation?.trivial, + } + : null, + methodName, + methodDescription, + method: node.method ?? null, + taskId: node.id ?? null, + taskStatus: node.status ?? null, + taskStatusOverall, + hasResults: safeProspects.length > 0, + emptyStateType, + errors: node.statusDetails?.errors ?? null, + }; + + if (safeProspects.length > 0) { + result.push( + ...safeProspects.map((item) => ({ + ...item, + ...common, + })), + ); + } else if (nodeIsStillRunning || nodeIsTerminal) { + result.push(common); + } + } + + if (Array.isArray(node.children)) { + node.children.forEach((child) => collectProspects(child, type, result)); + } + + return result; +}; + +export const getAllIndiv = (node, result = []) => + collectProspects(node, "indiv", result); +export const getAllAnnot = (node, result = []) => + collectProspects(node, "annot", result); + +const isLeafNode = (node) => { + if (!node || typeof node !== "object") return false; + return !Array.isArray(node.children) || node.children.length === 0; +}; + +const collectLeafNodes = (node, result = []) => { + if (!node || typeof node !== "object") return result; + + if (isLeafNode(node)) { + result.push(node); + return result; + } + + if (Array.isArray(node.children)) { + node.children.forEach((child) => collectLeafNodes(child, result)); + } + + return result; +}; + +const hasOwnStatusOverall = (node) => + !!node && + typeof node === "object" && + Object.prototype.hasOwnProperty.call(node, "statusOverall"); + +const isTerminalStatus = (status) => + status === "completed" || status === "error"; + +export const isMatchTaskStillRunning = (root) => { + if (!root || typeof root !== "object") return false; + + const leafNodes = collectLeafNodes(root); + const nodesToCheck = [root, ...leafNodes].filter(hasOwnStatusOverall); + + if (nodesToCheck.length === 0) return false; + + return nodesToCheck.some((node) => !isTerminalStatus(node.statusOverall)); +}; + +export const hasMatchTaskError = (root) => { + if (!root || typeof root !== "object") return false; + + const leafNodes = collectLeafNodes(root); + const nodesToCheck = [root, ...leafNodes].filter(hasOwnStatusOverall); + + return nodesToCheck.some((node) => node.statusOverall === "error"); +}; + +export const isMatchTaskTerminal = (root) => { + if (!root || typeof root !== "object") return true; + + const leafNodes = collectLeafNodes(root); + const nodesToCheck = [root, ...leafNodes].filter(hasOwnStatusOverall); + + if (nodesToCheck.length === 0) return true; + + return nodesToCheck.every((node) => isTerminalStatus(node.statusOverall)); +}; diff --git a/frontend/src/pages/MatchResultsPage/icons/ExitFullScreenIcon.jsx b/frontend/src/pages/MatchResultsPage/icons/ExitFullScreenIcon.jsx new file mode 100644 index 0000000000..607ae5f496 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/icons/ExitFullScreenIcon.jsx @@ -0,0 +1,51 @@ +import React from "react"; +import ThemeColorContext from "../../../ThemeColorProvider"; + +export default function ExitFullScreenIcon({ + onClick = () => {}, + style = {}, + className = "", +}) { + const themeColor = React.useContext(ThemeColorContext); + return ( +
{}} + > + + + +
+ ); +} diff --git a/frontend/src/pages/MatchResultsPage/icons/FilterIcon.jsx b/frontend/src/pages/MatchResultsPage/icons/FilterIcon.jsx new file mode 100644 index 0000000000..920ab1aa0e --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/icons/FilterIcon.jsx @@ -0,0 +1,35 @@ +import React from "react"; + +export default function FilterIcon({ + onClick = () => {}, + style = {}, + className = "", +}) { + return ( +
{}} + > + + + +
+ ); +} diff --git a/frontend/src/pages/MatchResultsPage/icons/FullScreenIcon.jsx b/frontend/src/pages/MatchResultsPage/icons/FullScreenIcon.jsx new file mode 100644 index 0000000000..435d1cff73 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/icons/FullScreenIcon.jsx @@ -0,0 +1,51 @@ +import React from "react"; +import ThemeColorContext from "../../../ThemeColorProvider"; + +export default function FullScreenIcon({ + onClick = () => {}, + style = {}, + className = "", +}) { + const themeColor = React.useContext(ThemeColorContext); + return ( +
{}} + > + + + +
+ ); +} diff --git a/frontend/src/pages/MatchResultsPage/icons/HatchMarkIcon.jsx b/frontend/src/pages/MatchResultsPage/icons/HatchMarkIcon.jsx new file mode 100644 index 0000000000..1aa6297da1 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/icons/HatchMarkIcon.jsx @@ -0,0 +1,51 @@ +import React from "react"; +import ThemeColorContext from "../../../ThemeColorProvider"; + +export default function HatchMarkIcon({ + onClick = () => {}, + style = {}, + className = "", +}) { + const themeColor = React.useContext(ThemeColorContext); + return ( +
{}} + > + + + +
+ ); +} diff --git a/frontend/src/pages/MatchResultsPage/icons/InfoIcon.jsx b/frontend/src/pages/MatchResultsPage/icons/InfoIcon.jsx new file mode 100644 index 0000000000..a7144afa9b --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/icons/InfoIcon.jsx @@ -0,0 +1,35 @@ +import React from "react"; + +export default function InfoIcon({ + onClick = () => {}, + style = {}, + className = "", +}) { + return ( +
{}} + > + + + +
+ ); +} diff --git a/frontend/src/pages/MatchResultsPage/icons/ToggleAnnotationIcon.jsx b/frontend/src/pages/MatchResultsPage/icons/ToggleAnnotationIcon.jsx new file mode 100644 index 0000000000..3f03537603 --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/icons/ToggleAnnotationIcon.jsx @@ -0,0 +1,51 @@ +import React from "react"; +import ThemeColorContext from "../../../ThemeColorProvider"; + +export default function ToggleAnnotationIcon({ + onClick = () => {}, + style = {}, + className = "", +}) { + const themeColor = React.useContext(ThemeColorContext); + return ( +
{}} + > + + + +
+ ); +} diff --git a/frontend/src/pages/MatchResultsPage/icons/ZoomInIcon.jsx b/frontend/src/pages/MatchResultsPage/icons/ZoomInIcon.jsx new file mode 100644 index 0000000000..4a0c31ee6f --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/icons/ZoomInIcon.jsx @@ -0,0 +1,31 @@ +import React from "react"; +import ThemeColorContext from "../../../ThemeColorProvider"; + +export default function ZoomInIcon({ + onClick = () => {}, + style = {}, + className = "" +}) { + const themeColor = React.useContext(ThemeColorContext); + return ( +
{ + }}> + + + +
) +} \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/icons/ZoomOutIcon.jsx b/frontend/src/pages/MatchResultsPage/icons/ZoomOutIcon.jsx new file mode 100644 index 0000000000..0784d05b5c --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/icons/ZoomOutIcon.jsx @@ -0,0 +1,31 @@ +import React from "react"; +import ThemeColorContext from "../../../ThemeColorProvider"; + +export default function ZoomInIcon({ + onClick = () => {}, + style = {}, + className = "" +}) { + const themeColor = React.useContext(ThemeColorContext); + return ( +
{ + }}> + + + +
) +} \ No newline at end of file diff --git a/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js b/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js new file mode 100644 index 0000000000..a93444401c --- /dev/null +++ b/frontend/src/pages/MatchResultsPage/stores/matchResultsStore.js @@ -0,0 +1,787 @@ +import { makeAutoObservable } from "mobx"; +import axios from "axios"; +import { toast } from "react-toastify"; +import { MAX_ROWS_PER_COLUMN } from "../constants"; +import { + getAllAnnot, + getAllIndiv, + isMatchTaskStillRunning, + hasMatchTaskError, + isMatchTaskTerminal, +} from "../helperFunctions"; + +export default class MatchResultsStore { + _viewMode = "individual"; // "individual" | "image" + _encounterId = ""; + _annotResults = []; + _indivResults = []; + _encounterLocationId = ""; + _useNextIndividualName = false; + _statusOverall = ""; + _matchingSetFilter = {}; + _individualId = null; + _individualDisplayName = null; + _projectNames = []; + _numResults = 12; + _selectedMatchImageUrlByAlgo = new Map(); + _selectedMatch = []; + _taskId = null; + _newIndividualName = ""; + + // raw data from API, before grouping / processing + _rawAnnots = []; + _rawIndivs = []; + _processedAnnots = []; + _processedIndivs = []; + + _loading = false; + _matchRequestLoading = false; + _matchRequestError = null; + _hasResults = false; + _taskStillRunning = false; + _taskHasError = false; + _taskIsTerminal = false; + _rootStillRunning = false; + _rootHasError = false; + _currentRequestId = null; + + constructor() { + makeAutoObservable(this, {}, { autoBind: true }); + } + + _findBestQuerySource() { + const candidates = [ + ...(Array.isArray(this._indivResults) ? this._indivResults : []), + ...(Array.isArray(this._annotResults) ? this._annotResults : []), + ]; + + if (candidates.length === 0) return null; + + const withEncounterId = candidates.find((item) => item?.queryEncounterId); + if (withEncounterId) return withEncounterId; + + const withQueryAnnotationEncounter = candidates.find( + (item) => item?.queryEncounterAnnotation?.encounter?.id, + ); + if (withQueryAnnotationEncounter) return withQueryAnnotationEncounter; + + const withSomeQueryContext = candidates.find( + (item) => + item?.queryEncounterImageAsset || + item?.queryEncounterImageUrl || + item?.queryEncounterAnnotation || + item?.matchingSetFilter || + item?.queryIndividualId, + ); + if (withSomeQueryContext) return withSomeQueryContext; + + return candidates[0]; + } + + loadData(results, { preserveSelection = false } = {}) { + const root = results?.matchResultsRoot; + + this._annotResults = getAllAnnot(root); + this._indivResults = getAllIndiv(root); + this._taskStillRunning = isMatchTaskStillRunning(root); + this._taskHasError = hasMatchTaskError(root); + this._taskIsTerminal = isMatchTaskTerminal(root); + + const rootHasChildren = + Array.isArray(root?.children) && root.children.length > 0; + const rootHasMatchResults = !!root?.matchResults; + + this._rootStillRunning = + !rootHasChildren && + !rootHasMatchResults && + !!root?.statusOverall && + root.statusOverall !== "completed" && + root.statusOverall !== "error"; + + this._rootHasError = + !rootHasChildren && + !rootHasMatchResults && + root?.statusOverall === "error"; + + const hasAnyResults = + (Array.isArray(this._annotResults) && this._annotResults.length > 0) || + (Array.isArray(this._indivResults) && this._indivResults.length > 0); + + if (!hasAnyResults) { + this._rawAnnots = []; + this._rawIndivs = []; + this._processedAnnots = []; + this._processedIndivs = []; + this._encounterId = null; + this._matchingSetFilter = {}; + this._individualId = null; + this._individualDisplayName = null; + this._hasResults = false; + this._encounterLocationId = ""; + this._statusOverall = root?.statusOverall || ""; + + if (!preserveSelection) { + this.resetSelectionToQuery(); + } + return; + } + + if (!this._annotResults || this._annotResults.length === 0) { + this._viewMode = "individual"; + } + if (!this._indivResults || this._indivResults.length === 0) { + this._viewMode = "image"; + } + + const querySource = this._findBestQuerySource(); + if (!querySource) return; + + this._encounterId = + querySource.queryEncounterId || + querySource.queryEncounterAnnotation?.encounter?.id || + null; + + this._encounterLocationId = querySource.encounterLocationId || ""; + this._matchingSetFilter = querySource.matchingSetFilter || {}; + this._individualId = querySource.queryIndividualId || null; + this._individualDisplayName = + querySource.queryIndividualDisplayName || null; + this._statusOverall = querySource.taskStatusOverall || ""; + + this._rawAnnots = Array.isArray(this._annotResults) + ? this._annotResults + : []; + this._rawIndivs = Array.isArray(this._indivResults) + ? this._indivResults + : []; + this._hasResults = this._rawAnnots.length > 0 || this._rawIndivs.length > 0; + this._processedAnnots = this._processData(this._rawAnnots); + this._processedIndivs = this._processData(this._rawIndivs); + + if (!preserveSelection) { + this.resetSelectionToQuery(); + } + } + + _processData(rawData) { + // 1. group by task + const groupedByTask = new Map(); + for (const item of rawData) { + const taskId = item.taskId || "unknown-task"; + if (!groupedByTask.has(taskId)) groupedByTask.set(taskId, []); + groupedByTask.get(taskId).push(item); + } + + //2. divide to columns + const sections = []; + + for (const [taskId, items] of groupedByTask) { + const sorted = items; + + const columns = []; + for (let i = 0; i < sorted.length; i += MAX_ROWS_PER_COLUMN) { + const columnData = sorted + .slice(i, i + MAX_ROWS_PER_COLUMN) + .map((data, index) => ({ + ...data, + displayIndex: i + index + 1, + })); + columns.push(columnData); + } + + const first = + sorted.find( + (item) => + item?.queryEncounterId || + item?.queryEncounterImageAsset || + item?.queryEncounterImageUrl || + item?.queryEncounterAnnotation || + item?.methodName || + item?.methodDescription || + item?.algorithm, + ) || + sorted[0] || + {}; + + sections.push({ + taskId, + columns, + metadata: { + numCandidates: first.numberCandidates ?? "-", + date: first.date, + queryImageUrl: + first.queryEncounterImageAsset?.url || first.queryEncounterImageUrl, + queryEncounterImageAsset: first.queryEncounterImageAsset, + queryEncounterAnnotation: first.queryEncounterAnnotation, + methodName: first.methodName, + methodDescription: first.methodDescription, + taskStatus: first.taskStatus, + taskStatusOverall: first.taskStatusOverall, + algorithm: first.algorithm, + emptyStateType: first.emptyStateType ?? null, + errors: first.errors ?? null, + }, + }); + } + + return sections; + } + + clearResults() { + this._annotResults = []; + this._indivResults = []; + this._rawAnnots = []; + this._rawIndivs = []; + this._processedAnnots = []; + this._processedIndivs = []; + this._encounterId = null; + this._encounterLocationId = ""; + this._matchingSetFilter = {}; + this._individualId = null; + this._individualDisplayName = null; + this._statusOverall = ""; + this._viewMode = "individual"; + this._newIndividualName = ""; + this._hasResults = false; + this._taskStillRunning = false; + this._taskHasError = false; + this._taskIsTerminal = false; + this._rootStillRunning = false; + this._rootHasError = false; + + this.resetSelectionToQuery(); + } + + // --- computed data for UI --- + + get processedAnnots() { + return this._processedAnnots; + } + + get processedIndivs() { + return this._processedIndivs; + } + + get currentViewData() { + return this._viewMode === "individual" + ? this._processedIndivs + : this._processedAnnots; + } + + get viewMode() { + return this._viewMode; + } + + get taskStillRunning() { + return this._taskStillRunning; + } + + get taskHasError() { + return this._taskHasError; + } + + get taskIsTerminal() { + return this._taskIsTerminal; + } + + get rootStillRunning() { + return this._rootStillRunning; + } + + get rootHasError() { + return this._rootHasError; + } + + get shouldPoll() { + return !!this._taskId && this._taskStillRunning; + } + + get hasDisplaySections() { + return this.currentViewData.length > 0; + } + + get encounterId() { + return this._encounterId; + } + + get encounterLocationId() { + return this._encounterLocationId; + } + + get matchingSetFilter() { + return this._matchingSetFilter; + } + + get individualId() { + return this._individualId; + } + + get individualDisplayName() { + return this._individualDisplayName; + } + + get projectNames() { + return this._projectNames; + } + + get numResults() { + return this._numResults; + } + + get loading() { + return this._loading; + } + + get matchRequestLoading() { + return this._matchRequestLoading; + } + + get matchRequestError() { + return this._matchRequestError; + } + + get hasResults() { + return this._hasResults; + } + + get newIndividualName() { + return this._newIndividualName; + } + + get taskId() { + return this._taskId; + } + + get selectedMatch() { + return this._selectedMatch; + } + + get uniqueIndividualIds() { + const ids = new Set(); + + if (this._individualId) { + ids.add(this._individualId); + } + + this._selectedMatch.forEach((match) => { + if (match.individualId) { + ids.add(match.individualId); + } + }); + + return Array.from(ids); + } + + get querySelectionItem() { + if (!this._encounterId) return null; + return { + encounterId: this._encounterId, + individualId: this._individualId || null, + individualDisplayName: this.individualDisplayName || null, + }; + } + + get selectedIncludingQuery() { + const selected = Array.isArray(this._selectedMatch) + ? this._selectedMatch + : []; + const q = this.querySelectionItem; + if (!q) return selected; + + const withoutQueryDup = selected.filter( + (m) => m?.encounterId && m.encounterId !== q.encounterId, + ); + + return [q, ...withoutQueryDup]; + } + + async fetchMatchResults({ silent = false } = {}) { + if (!this._taskId) return; + + // Capture request context to detect stale responses + const requestId = Date.now() + Math.random(); + this._currentRequestId = requestId; + const capturedTaskId = this._taskId; + + const params = new URLSearchParams(); + params.set("prospectsSize", String(this.numResults)); + + if (Array.isArray(this._projectNames) && this._projectNames.length > 0) { + this._projectNames.forEach((projectId) => + params.append("projectId", projectId), + ); + } + + if (!silent) { + this.setLoading(true); + this.clearResults(); + + try { + const result = await axios.get( + `/api/v3/tasks/${this._taskId}/match-results?${params.toString()}`, + ); + // Discard stale responses + if (this._currentRequestId !== requestId || this._taskId !== capturedTaskId) { + return; + } + this.loadData(result?.data, { preserveSelection: false }); + } catch (e) { + this.clearResults(); + toast.error("Failed to load match results"); + } finally { + // Only clear loading if this is still the current request + if (this._currentRequestId === requestId) { + this.setLoading(false); + } + } + } else { + try { + const result = await axios.get( + `/api/v3/tasks/${this._taskId}/match-results?${params.toString()}`, + ); + // Discard stale responses + if (this._currentRequestId !== requestId || this._taskId !== capturedTaskId) { + return; + } + + const root = result?.data?.matchResultsRoot; + + const stillRunning = isMatchTaskStillRunning(root); + const annLen = (getAllAnnot(root) || []).length; + const indLen = (getAllIndiv(root) || []).length; + const hasAnyResults = annLen > 0 || indLen > 0; + + if (stillRunning && !hasAnyResults) { + return; + } + + this.loadData(result?.data, { preserveSelection: true }); + } catch (e) { + throw new Error("Failed to silently refresh match results: " + (e?.message || String(e))); + } + } + } + + // setters and actions + + setLoading(loading) { + this._loading = loading; + } + + setHasResults(results) { + this._hasResults = results; + } + + setTaskId(id) { + this._taskId = id; + } + + setViewMode(mode) { + this._viewMode = mode; + } + + setNumResults(n) { + this._numResults = n; + } + + setProjectNames(names, { fetch = true } = {}) { + const next = Array.isArray(names) ? names : []; + + // Compare sorted copies to avoid spurious refetches when order differs + const currentSorted = [...this._projectNames].sort(); + const nextSorted = [...next].sort(); + if (JSON.stringify(currentSorted) === JSON.stringify(nextSorted)) return; + + this._projectNames = next; + + if (fetch && this._taskId) { + this.fetchMatchResults(); + } + } + + setNewIndividualName(name, useNextName = false) { + this._useNextIndividualName = useNextName; + this._newIndividualName = name; + } + + async handleCreateNewIndividual(selectedRemark) { + this._matchRequestLoading = true; + this._matchRequestError = null; + + try { + const newName = (this._newIndividualName || "").trim(); + + if (!newName) { + this._matchRequestError = "ENTER_INDIVIDUAL_NAME"; + toast.error("Please enter a new individual name"); + return { ok: false, error: "ENTER_INDIVIDUAL_NAME" }; + } + + const encounterIds = Array.from( + new Set( + this.selectedIncludingQuery + .filter((m) => !m.individualId) + .map((m) => m.encounterId), + ), + ); + + if (encounterIds.length === 0) { + this._matchRequestError = "NO_ENCOUNTERS_TO_UPDATE"; + toast.error("No encounters to update"); + return { ok: false, error: "NO_ENCOUNTERS_TO_UPDATE" }; + } + + let patchOps = []; + + if (this._useNextIndividualName) { + patchOps = [ + { + op: "replace", + path: "individualId", + value: { + type: "locationId", + value: this._encounterLocationId, + }, + }, + ]; + } else { + patchOps = [{ op: "replace", path: "individualId", value: newName }]; + } + + if (selectedRemark && selectedRemark.trim() !== "") { + patchOps.push({ + op: "replace", + path: "identificationRemarks", + value: selectedRemark, + }); + } + + // Run all PATCHes in parallel and track results + const patchPromises = encounterIds.map((id) => + axios.patch( + `/api/v3/encounters/${encodeURIComponent(id)}`, + patchOps, + { + headers: { + "Content-Type": "application/json-patch+json", + Accept: "application/json", + }, + }, + ).then( + (response) => ({ status: "fulfilled", encounterId: id, response }), + (error) => ({ status: "rejected", encounterId: id, error }), + ), + ); + + const results = await Promise.allSettled(patchPromises); + + // Separate successes and failures + const successes = []; + const failures = []; + + for (const result of results) { + if (result.status === "fulfilled") { + const { status, encounterId, error } = result.value; + if (status === "fulfilled") { + successes.push(encounterId); + } else { + failures.push({ encounterId, error }); + } + } + } + + // If any failed, show detailed error + if (failures.length > 0) { + const failedIds = failures.map((f) => f.encounterId).join(", "); + this._matchRequestError = "CREATE_NEW_INDIVIDUAL_PARTIAL"; + toast.error( + `Failed to update ${failures.length} of ${encounterIds.length} encounters: ${failedIds}`, + ); + return { + ok: false, + error: "CREATE_NEW_INDIVIDUAL_PARTIAL", + successes, + failures: failures.map((f) => ({ encounterId: f.encounterId, error: f.error?.message || String(f.error) })), + }; + } + + this.resetSelectionToQuery(); + toast.success("New individual created successfully!"); + return { ok: true, successes }; + } catch (e) { + this._matchRequestError = "CREATE_NEW_INDIVIDUAL_FAILED"; + toast.error("Failed to create new individual"); + return { ok: false, error: "CREATE_NEW_INDIVIDUAL_FAILED" }; + } finally { + this._matchRequestLoading = false; + } + } + + setSelectedMatch( + selected, + key, + encounterId, + individualId, + individualDisplayName, + ) { + if (!key || !encounterId) return; + + if (selected) { + if (this._selectedMatch.some((m) => m.key === key)) return; + this._selectedMatch = [ + ...this._selectedMatch, + { + key, + encounterId, + individualId: individualId || null, + individualDisplayName: individualDisplayName || null, + }, + ]; + } else { + this._selectedMatch = this._selectedMatch.filter((m) => m.key !== key); + } + } + + clearSelection() { + this._selectedMatch = []; + this._matchRequestError = null; + } + + // merge functions + + //no further action needed + handleNoFurtherActionNeeded() { + this.clearSelection(); + return { ok: true, noop: true }; + } + + //one individual + async handleMatch() { + this._matchRequestLoading = true; + this._matchRequestError = null; + + try { + const all = this.selectedIncludingQuery; + + const uniqueIndividuals = Array.from( + new Set(all.map((m) => m?.individualId).filter(Boolean)), + ); + + if (uniqueIndividuals.length !== 1) { + this._matchRequestError = "MATCH_REQUIRES_SINGLE_INDIVIDUAL"; + toast.error("Please select exactly one target individual"); + return null; + } + + const targetIndividualId = uniqueIndividuals[0]; + + const unnamedEncounterIds = Array.from( + new Set( + all + .filter((m) => m?.encounterId && !m?.individualId) + .map((m) => m.encounterId) + .filter(Boolean), + ), + ); + + const params = new URLSearchParams(); + if (this._encounterId) params.set("number", this._encounterId); + if (this._taskId) params.set("taskId", this._taskId); + params.set("individualID", targetIndividualId); + + unnamedEncounterIds + .filter((id) => id !== this._encounterId) + .forEach((id) => params.append("encOther", id)); + + const url = `/iaResultsSetID.jsp?${params.toString()}`; + + const res = await axios.get(url, { + headers: { Accept: "application/json" }, + }); + + this.resetSelectionToQuery(); + toast.success("Match confirmed successfully!"); + return res.data; + } catch (e) { + this._matchRequestError = "MATCH_FAILED"; + toast.error("Failed to confirm match"); + return null; + } finally { + this._matchRequestLoading = false; + } + } + + //merge two individuals and encounters + async handleMerge() { + this._matchRequestLoading = true; + this._matchRequestError = null; + + try { + const all = this.selectedIncludingQuery; + + const uniqueIndividuals = Array.from( + new Set(all.map((m) => m?.individualId).filter(Boolean)), + ); + + if (uniqueIndividuals.length !== 2) { + this._matchRequestError = "MERGE_REQUIRES_TWO_INDIVIDUALS"; + toast.error("Please select exactly two individuals to merge"); + return null; + } + + const [individualA, individualB] = uniqueIndividuals; + + const unnamedEncounterIds = Array.from( + new Set( + all + .filter((m) => m?.encounterId && !m?.individualId) + .map((m) => m.encounterId) + .filter(Boolean), + ), + ); + + const params = new URLSearchParams(); + params.set("individualA", individualA); + params.set("individualB", individualB); + unnamedEncounterIds.forEach((id) => params.append("encounterId", id)); + + const url = `/merge.jsp?${params.toString()}`; + window.open(url, "_blank"); + + this.resetSelectionToQuery(); + toast.success("Merge page opened successfully!"); + return { ok: true }; + } catch (e) { + this._matchRequestError = "MERGE_FAILED"; + toast.error("Failed to start merge"); + return null; + } finally { + this._matchRequestLoading = false; + } + } + + resetSelectionToQuery() { + this._selectedMatch = []; + this._matchRequestError = null; + } + + get matchingState() { + const all = this.selectedIncludingQuery; + + const uniqueIndividuals = Array.from( + new Set(all.map((m) => m?.individualId).filter(Boolean)), + ); + + const allHaveIndividual = + all.length > 0 && all.every((m) => m?.encounterId && m?.individualId); + + if (uniqueIndividuals.length === 0) return "no_individuals"; + if (uniqueIndividuals.length === 1) { + return allHaveIndividual + ? "no_further_action_needed" + : "single_individual"; + } + if (uniqueIndividuals.length === 2) return "two_individuals"; + return "too_many_individuals"; + } +} diff --git a/frontend/src/pages/ReportsAndManagamentPages/ImageSection.jsx b/frontend/src/pages/ReportsAndManagamentPages/ImageSection.jsx index 2521425cfb..0dbe96a66f 100644 --- a/frontend/src/pages/ReportsAndManagamentPages/ImageSection.jsx +++ b/frontend/src/pages/ReportsAndManagamentPages/ImageSection.jsx @@ -10,7 +10,7 @@ import { FormattedMessage } from "react-intl"; import ThemeContext from "../../ThemeColorProvider"; import MainButton from "../../components/MainButton"; import { v4 as uuidv4 } from "uuid"; -import useGetSiteSettings from "../../models/useGetSiteSettings"; +import { useSiteSettings } from "../../SiteSettingsContext"; import { observer } from "mobx-react-lite"; import { Alert } from "react-bootstrap"; import EXIF from "exif-js"; @@ -23,7 +23,7 @@ export const ImageSection = observer(({ store }) => { const [_uploading, setUploading] = useState(false); const [previewData, setPreviewData] = useState([]); const fileInputRef = useRef(null); - const { data } = useGetSiteSettings(); + const { data } = useSiteSettings(); const maxSize = data?.maximumMediaSizeMegabytes || defaultMaxMediaSize; const theme = useContext(ThemeContext); const originalBorder = `1px dashed ${theme.primaryColors.primary500}`; diff --git a/frontend/src/pages/ReportsAndManagamentPages/PlaceSection.jsx b/frontend/src/pages/ReportsAndManagamentPages/PlaceSection.jsx index 9b892f9fd8..f7ce2646f4 100644 --- a/frontend/src/pages/ReportsAndManagamentPages/PlaceSection.jsx +++ b/frontend/src/pages/ReportsAndManagamentPages/PlaceSection.jsx @@ -3,13 +3,13 @@ import { Form } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import { observer } from "mobx-react-lite"; import { Loader } from "@googlemaps/js-api-loader"; -import useGetSiteSettings from "../../models/useGetSiteSettings"; +import { useSiteSettings } from "../../SiteSettingsContext"; import "./reportEncounter.css"; import { LocationID } from "./LocationID"; import { Alert } from "react-bootstrap"; export const PlaceSection = observer(({ store }) => { - const { data } = useGetSiteSettings(); + const { data } = useSiteSettings(); const mapCenterLat = data?.mapCenterLat || 51; const mapCenterLon = data?.mapCenterLon || 7; const mapZoom = data?.mapZoom || 4; diff --git a/frontend/src/pages/ReportsAndManagamentPages/ReportEncounter.jsx b/frontend/src/pages/ReportsAndManagamentPages/ReportEncounter.jsx index 56c4f32d91..e23eae31be 100644 --- a/frontend/src/pages/ReportsAndManagamentPages/ReportEncounter.jsx +++ b/frontend/src/pages/ReportsAndManagamentPages/ReportEncounter.jsx @@ -13,14 +13,14 @@ import { observer, useLocalObservable } from "mobx-react-lite"; import { ReportEncounterStore } from "./ReportEncounterStore"; import { ReportEncounterSpeciesSection } from "./SpeciesSection"; import { useNavigate } from "react-router-dom"; -import useGetSiteSettings from "../../models/useGetSiteSettings"; +import { useSiteSettings } from "../../SiteSettingsContext"; import "./recaptcha.css"; const ReportEncounter = observer(() => { const themeColor = useContext(ThemeColorContext); const { isLoggedIn } = useContext(AuthContext); const Navigate = useNavigate(); - const { data } = useGetSiteSettings(); + const { data } = useSiteSettings(); const procaptchaSiteKey = data?.procaptchaSiteKey; const store = useLocalObservable(() => new ReportEncounterStore()); const [missingField, setMissingField] = useState(false); diff --git a/frontend/src/pages/ReportsAndManagamentPages/SpeciesSection.jsx b/frontend/src/pages/ReportsAndManagamentPages/SpeciesSection.jsx index 7ebe1efb3a..19ef2168e7 100644 --- a/frontend/src/pages/ReportsAndManagamentPages/SpeciesSection.jsx +++ b/frontend/src/pages/ReportsAndManagamentPages/SpeciesSection.jsx @@ -1,11 +1,11 @@ import React from "react"; import { Form, Alert } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; -import useGetSiteSettings from "../../models/useGetSiteSettings"; +import { useSiteSettings } from "../../SiteSettingsContext"; import { observer } from "mobx-react-lite"; export const ReportEncounterSpeciesSection = observer(({ store }) => { - const { data } = useGetSiteSettings(); + const { data } = useSiteSettings(); let speciesList = data?.siteTaxonomies?.map((item) => { return { diff --git a/src/main/java/org/ecocean/Encounter.java b/src/main/java/org/ecocean/Encounter.java index b7c972c26b..0a65e19a03 100644 --- a/src/main/java/org/ecocean/Encounter.java +++ b/src/main/java/org/ecocean/Encounter.java @@ -2521,6 +2521,21 @@ public List getProjects(Shepherd myShepherd) { return myShepherd.getProjectsForEncounter(this); } + public boolean isInProjects(Set projectIds, Shepherd myShepherd) { + // if we dont have any ids, here we are going to consider it false + // NOTE: opposite logic in MatchResultProspect.isInProject() + if (Util.collectionIsEmptyOrNull(projectIds)) return false; + String sql = "select count(*) from \"PROJECT_ENCOUNTERS\" where \"CATALOGNUMBER_EID\" = '" + + this.getId() + "' and \"ID_OID\" in ('" + String.join("', '", projectIds) + "')"; + Query q = myShepherd.getPM().newQuery("javax.jdo.query.SQL", sql); + List results = (List)q.execute(); + Iterator it = results.iterator(); + if (!it.hasNext()) return false; + Long count = (Long)it.next(); + q.closeAll(); + return (count > 0); + } + public void addTissueSample(TissueSample dce) { if (tissueSamples == null) { tissueSamples = new ArrayList(); } if (!tissueSamples.contains(dce)) { tissueSamples.add(dce); } diff --git a/src/main/java/org/ecocean/Util.java b/src/main/java/org/ecocean/Util.java index 15ce95fb9e..034cf544bb 100644 --- a/src/main/java/org/ecocean/Util.java +++ b/src/main/java/org/ecocean/Util.java @@ -718,6 +718,21 @@ public static String prettyTimeStamp() { return sdf.format(new Date()); } + public static String millisToHumanApprox(Long millis) { + if (millis == null) return "unknown"; + if (millis < 2000L) return "1 second"; + if (millis < 60000L) return Math.round(millis / 1000L) + " seconds"; + if (millis < 60L * 60L * 1000L) return Math.round(millis / (60L * 1000L)) + " minutes"; + if (millis < 24L * 60L * 60L * 1000L) return Math.round(millis / (60L * 60L * 1000L)) + " hours"; + return Math.round(millis / (24L * 60L * 60L * 1000L)) + " days"; + } + + public static String millisToISO8601String(Long millis) { + if (millis == null) return null; + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX"); + return sdf.format(new Date(millis)); + } + public static boolean dateTimeIsOnlyDate(DateTime dt) { try { return (dt.millisOfDay().get() == 0); diff --git a/src/main/java/org/ecocean/api/GenericObject.java b/src/main/java/org/ecocean/api/GenericObject.java index afef3377ab..b1cf375b69 100644 --- a/src/main/java/org/ecocean/api/GenericObject.java +++ b/src/main/java/org/ecocean/api/GenericObject.java @@ -2,6 +2,9 @@ import java.io.IOException; import java.net.URL; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.ServletException; @@ -11,6 +14,7 @@ import org.ecocean.Annotation; import org.ecocean.Encounter; +import org.ecocean.ia.Task; import org.ecocean.media.Feature; import org.ecocean.media.MediaAsset; import org.ecocean.media.MediaAssetFactory; @@ -24,6 +28,9 @@ public class GenericObject extends ApiBase { protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String context = ServletUtilities.getContext(request); + // normally false for GET, but some deep behavior creates objects on-the-fly + // and therefore needs to commit to db + boolean commitShepherd = false; Shepherd myShepherd = new Shepherd(context); myShepherd.setAction("api.GenericObject.doGet"); @@ -89,6 +96,47 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) } } break; + case "tasks": + if (currentUser == null) { + rtn.put("statusCode", 401); + rtn.put("error", "access denied"); + } else { + if ((args.length > 2) && ("match-results".equals(args[2]))) { + Task task = myShepherd.getTask(args[1]); + if (task == null) { + rtn.put("statusCode", 404); + rtn.put("error", "not found"); + } else { + // right now we replicate legacy functionality and allow access to anyone + // based on task id only, rather than blocking based on task.canUserAccess() + int prospectsSize = org.ecocean.ia.MatchResult.DEFAULT_PROSPECTS_CUTOFF; + Set projectIds = null; + String[] pvals = request.getParameterValues("projectId"); + if ((pvals != null) && (pvals.length > 0)) + projectIds = new HashSet(Arrays.asList( + request.getParameterValues("projectId"))); + try { + // note: negative size means all of them (no cutoff) + prospectsSize = Integer.parseInt(request.getParameter( + "prospectsSize")); + } catch (NumberFormatException ex) {} + rtn.put("prospectsSize", prospectsSize); + JSONObject mrJson = task.matchResultsJson(prospectsSize, projectIds, + myShepherd); + rtn.put("projectIds", projectIds); + rtn.put("matchResultsRoot", mrJson); + rtn.put("success", true); + rtn.put("statusCode", 200); + // this means we created on-the-fly some MatchResult(s) that need persisting + commitShepherd = (mrJson != null) && + mrJson.optBoolean("_commitShepherd", false); + if (commitShepherd) myShepherd.commitDBTransaction(); + } + } else { + throw new ApiException("invalid tasks operation"); + } + } + break; default: throw new ApiException("bad class"); } @@ -97,7 +145,11 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) rtn.put("errors", apiEx.getErrors()); rtn.put("debug", apiEx.toString()); } finally { - myShepherd.rollbackAndClose(); + if (commitShepherd) { + myShepherd.closeDBTransaction(); + } else { + myShepherd.rollbackAndClose(); + } } response.setStatus(rtn.optInt("statusCode", 500)); response.setCharacterEncoding("UTF-8"); diff --git a/src/main/java/org/ecocean/api/SiteSettings.java b/src/main/java/org/ecocean/api/SiteSettings.java index 438c27d389..67be168de7 100644 --- a/src/main/java/org/ecocean/api/SiteSettings.java +++ b/src/main/java/org/ecocean/api/SiteSettings.java @@ -183,9 +183,10 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) settings.put("keywordId", kwIdArr); // we map to a Set here so we keep values unique (remove duplicates: issue 1279) - Map> allLK = new HashMap>(); + Map > allLK = new HashMap >(); for (LabeledKeyword lkw : myShepherd.getAllLabeledKeywords()) { - if (!allLK.containsKey(lkw.getLabel())) allLK.put(lkw.getLabel(), new HashSet()); + if (!allLK.containsKey(lkw.getLabel())) + allLK.put(lkw.getLabel(), new HashSet()); allLK.get(lkw.getLabel()).add(lkw.getValue()); } JSONObject lkeyword = new JSONObject(); @@ -299,7 +300,11 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response) ArrayList projs = myShepherd.getProjectsForUser(currentUser); if (projs != null) { for (Project proj : projs) { - jp.put(proj.getId(), proj.getResearchProjectName()); + JSONObject info = new JSONObject(); + info.put("id", proj.getId()); + info.put("name", proj.getResearchProjectName()); + info.put("prefix", proj.getProjectIdPrefix()); + jp.put(proj.getId(), info); } } settings.put("projectsForUser", jp); diff --git a/src/main/java/org/ecocean/ia/IA.java b/src/main/java/org/ecocean/ia/IA.java index b592d38a89..a922657b35 100644 --- a/src/main/java/org/ecocean/ia/IA.java +++ b/src/main/java/org/ecocean/ia/IA.java @@ -433,7 +433,7 @@ public static void handleRest(JSONObject jin) { } Task mtask = intakeMediaAssets(myShepherd, mas, topTask); System.out.println("INFO: IA.handleRest() just intook MediaAssets as " + mtask + - " for " + topTask); + " for (parent) " + topTask); topTask.addChild(mtask); } JSONArray alist = jin.optJSONArray("annotationIds"); @@ -465,8 +465,10 @@ public static void handleRest(JSONObject jin) { Task atask = intakeAnnotations(myShepherd, anns, topTask, fastlane); System.out.println("INFO: IA.handleRest() just intook Annotations as " + atask + " for " + topTask); - topTask.addChild(atask); myShepherd.getPM().refresh(topTask); + topTask.addChild(atask); + topTask.setModified(); + myShepherd.getPM().makePersistent(atask); } myShepherd.commitDBTransaction(); } catch (Exception e) { diff --git a/src/main/java/org/ecocean/ia/MatchResult.java b/src/main/java/org/ecocean/ia/MatchResult.java new file mode 100644 index 0000000000..ec3316cde1 --- /dev/null +++ b/src/main/java/org/ecocean/ia/MatchResult.java @@ -0,0 +1,516 @@ +package org.ecocean.ia; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.json.JSONArray; +import org.json.JSONObject; + +import org.ecocean.api.UploadedFiles; +import org.ecocean.Annotation; +import org.ecocean.Encounter; +// import org.ecocean.ia.MLService; +import org.ecocean.ia.Task; +import org.ecocean.identity.IBEISIA; +import org.ecocean.identity.IdentityServiceLog; +import org.ecocean.media.AssetStore; +import org.ecocean.media.Feature; +import org.ecocean.media.MediaAsset; +import org.ecocean.media.URLAssetStore; +import org.ecocean.MarkedIndividual; +import org.ecocean.RestClient; +import org.ecocean.shepherd.core.Shepherd; +import org.ecocean.Util; + +public class MatchResult implements java.io.Serializable { + private String id; + private long created; + private Task task; + private Set prospects; + private Annotation queryAnnotation; + private int numberCandidates = 0; + // not sure we really *need* true fk link to these annots + // they might be gone now and will we ever use this? + // so for now we just populate numberCandidates + private Set candidates; + // fallback number to cutoff number of prospects to return + public static final int DEFAULT_PROSPECTS_CUTOFF = 100; + + public MatchResult() { + id = Util.generateUUID(); + created = System.currentTimeMillis(); + } + + public MatchResult(Task task) { + this(); + this.task = task; + } + + public MatchResult(IdentityServiceLog isLog, Shepherd myShepherd) + throws IOException { + this(); + this.createFromIdentityServiceLog(isLog, myShepherd); + } + + public MatchResult(Task task, JSONObject jsonResult, Shepherd myShepherd) + throws IOException { + this(); + this.task = task; + this.createFromJsonResult(jsonResult, myShepherd); + } + + public MatchResult(Task task, List annots, int numberCandidates, + Shepherd myShepherd) + throws IOException { + this(); + this.task = task; + this.numberCandidates = numberCandidates; + this.setQueryAnnotationFromTask(); + // we populate prospects with both annot and indiv (per legacy) and it gets seperated out later + this.populateProspects(annots, false, myShepherd); + this.populateProspects(annots, true, myShepherd); + } + + public int getNumberCandidates() { + return numberCandidates; + } + + public void createFromIdentityServiceLog(IdentityServiceLog isLog, Shepherd myShepherd) + throws IOException { + if (isLog == null) throw new IOException("log passed is null"); + String taskId = isLog.getTaskID(); + this.task = myShepherd.getTask(taskId); + if (this.task == null) throw new IOException("task is null for taskId=" + taskId); + JSONObject res = isLog.getJsonResult(); + if (res == null) { + System.out.println("ERROR: getJsonResult() failed on " + isLog + " with status=" + + isLog.getStatusJson()); + throw new IOException("could not get json result"); + } + createFromJsonResult(res, myShepherd); + } + + public Annotation setQueryAnnotationFromTask() + throws IOException { + if (this.task == null) + throw new IOException("setQueryAnnotationFromTask() failed as task is null"); + int numAnns = this.task.countObjectAnnotations(); + if (numAnns < 1) + throw new IOException("setQueryAnnotationFromTask() failed as task has no annotations"); + if (numAnns > 1) + System.out.println("WARNING: setQueryAnnotationFromTask() has " + numAnns + + " annotations; using first"); + this.queryAnnotation = this.task.getObjectAnnotations().get(0); + return this.queryAnnotation; + } + + // json_result section should be passed here + public void createFromJsonResult(JSONObject res, Shepherd myShepherd) + throws IOException { + if (res == null) throw new IOException("null json_result passed"); + if (res.optJSONArray("query_annot_uuid_list") == null) + throw new IOException("no query annot list"); + if (res.getJSONArray("query_annot_uuid_list").length() < 1) + throw new IOException("empty query annot list"); + // for now we are assuming a single query annot. sorrynotsorry. + String queryAnnotId = IBEISIA.fromFancyUUID(res.getJSONArray( + "query_annot_uuid_list").optJSONObject(0)); + this.queryAnnotation = getAnnotationFromAcmId(queryAnnotId, myShepherd); + if (this.queryAnnotation == null) + throw new IOException("failed to load query annot from id=" + queryAnnotId); + if (res.optJSONObject("cm_dict") == null) + throw new IOException("no cm_dict found in " + res); + // results is the real scores (etc) we are looking for.... finally! + JSONObject results = res.getJSONObject("cm_dict").optJSONObject(queryAnnotId); + if (results == null) throw new IOException("no actual results found"); + // see note at top about true annot list of candidates vs number + if (res.optJSONArray("database_annot_uuid_list") != null) + this.numberCandidates = res.getJSONArray("database_annot_uuid_list").length(); +/* + annot_score_list <=> dannot_uuid_list + score_list is for indiv scores but on dannot_uuid_list (same length) + name_score_list <=> unique_name_uuid_list ??? + */ + this.populateProspects("annot", results.optJSONArray("dannot_uuid_list"), + results.optJSONArray("annot_score_list"), results.optJSONArray("dannot_extern_list"), + results.optString("dannot_extern_reference", null), myShepherd); + this.populateProspects("indiv", results.optJSONArray("dannot_uuid_list"), + results.optJSONArray("score_list"), results.optJSONArray("dannot_extern_list"), + results.optString("dannot_extern_reference", null), myShepherd); + System.out.println("[DEBUG] createFromJsonResult() created " + this); + } + + private int populateProspects(String type, JSONArray annotIds, JSONArray scores, + JSONArray externs, String externRef, Shepherd myShepherd) + throws IOException { + if ((annotIds == null) || (scores == null)) + throw new IOException("null annotIds or scores"); + if (annotIds.length() != scores.length()) + throw new IOException("mismatch in size of annotIds/scores"); + if (this.prospects == null) + this.prospects = new HashSet(); + int num = 0; + for (int i = 0; i < annotIds.length(); i++) { + double score = scores.optDouble(i, -Double.MAX_VALUE); + String id = IBEISIA.fromFancyUUID(annotIds.optJSONObject(i)); + Annotation ann = getAnnotationFromAcmId(id, myShepherd); + if (ann == null) { + System.out.println("WARNING: populateProspect failed to load annotId=" + id + + "; skipping; score=" + score); + continue; + } + MediaAsset ma = null; + // we only try if we have a true value in externs[i] + if ((externs != null) && (externs.length() > i) && externs.optBoolean(i, false)) + ma = createInspectionHeatmapAsset(externRef, id, myShepherd); + this.prospects.add(new MatchResultProspect(ann, score, type, ma)); + num++; + } + return num; + } + + // we just have a list of annots which matched (e.g. via vectors in opensearch) + private int populateProspects(List annots, boolean scoreByIndividual, + Shepherd myShepherd) + throws IOException { + if (Util.collectionIsEmptyOrNull(annots)) return 0; + if (this.prospects == null) + this.prospects = new HashSet(); + if (scoreByIndividual) { + // the scores for these are calculated weighted by indiv count + _populateProspectsByIndividual(annots, myShepherd); + } else { + // these scores are direct from opensearch + for (Annotation ann : annots) { + MediaAsset ma = createInspectionPairxAsset(this.queryAnnotation, ann, myShepherd); + // TODO FIXME - getOpensearchScore() comes in via vector branch - replace this in the merged future + // this.prospects.add(new MatchResultProspect(ann, ann.getOpensearchScore(), "annot", ma)); + this.prospects.add(new MatchResultProspect(ann, 0.0d, "annot", ma)); + } + } + return this.prospects.size(); + } + + private void _populateProspectsByIndividual(List annots, Shepherd myShepherd) { + Map > tally = new HashMap >(); + + for (Annotation ann : annots) { + Encounter enc = ann.findEncounter(myShepherd); + // i think we just ignore if no enc/indiv + if (enc == null) continue; + MarkedIndividual indiv = enc.getIndividual(); + if (indiv == null) continue; + if (!tally.containsKey(indiv)) tally.put(indiv, new ArrayList()); + tally.get(indiv).add(ann); + } + if (tally.size() < 1) return; // no individuals i guess? + + // this sorts by most annots (per indiv) highest to lowest + List > > sorted = new ArrayList<>(tally.entrySet()); + // Collections.sort(sorted, new Comparator>>() { + sorted.sort(new Comparator > >() { + public int compare(Map.Entry > one, + Map.Entry > two) { + // we reverse order here so we get largest first + return Integer.compare(two.getValue().size(), one.getValue().size()); + } + }); + int most = sorted.get(0).getValue().size(); // top num of annots + for (Map.Entry > ent : sorted) { + double score = new Double(ent.getValue().size()) / new Double(most); + // the ent value (annot List) should always have at least one annot, so we use first one + MediaAsset ma = createInspectionPairxAsset(this.queryAnnotation, ent.getValue().get(0), + myShepherd); + this.prospects.add(new MatchResultProspect(ent.getValue().get(0), score, "indiv", ma)); + } + } + + private Annotation getAnnotationFromAcmId(String acmId, Shepherd myShepherd) { + if (acmId == null) return null; + Annotation found = findAcmIdInTaskAnnotations(acmId); + if (found != null) return found; + List anns = myShepherd.getAnnotationsWithACMId(acmId, true); + System.out.println("[WARNING] getAnnotationFromAcmId() failed to find " + acmId + + " in task annots; loaded by acmId " + Util.collectionSize(anns) + " annot(s)"); + if ((anns == null) || (anns.size() < 1)) return null; + return anns.get(0); + } + + private Annotation findAcmIdInTaskAnnotations(String acmId) { + if ((this.task == null) || (acmId == null)) return null; + if (!this.task.hasObjectAnnotations()) return null; + for (Annotation ann : this.task.getObjectAnnotations()) { + if (acmId.equals(ann.getAcmId())) return ann; + } + return null; + } + + // if it exists, we just return the thing, other wise we attempt to create it + public MediaAsset createInspectionHeatmapAsset(String externRef, String annotId, + Shepherd myShepherd) { + if (externRef == null) return null; + String url = "/api/query/graph/match/thumb/?extern_reference=" + externRef; + url += "&query_annot_uuid=" + this.queryAnnotation.getAcmId(); + url += "&database_annot_uuid=" + annotId; + url += "&version=heatmask"; + URL fullUrl = IBEISIA.iaURL(myShepherd.getContext(), url); + File tmpFile = new File("/tmp/extern-" + this.id + "-" + externRef + "-" + + this.queryAnnotation.getId() + "-" + annotId + ".jpg"); + System.out.println("[DEBUG] trying extern fetch url=" + fullUrl + " => " + tmpFile); + MediaAsset ma = null; + try { + URLAssetStore.fetchFileFromURL(fullUrl, tmpFile); + ma = UploadedFiles.makeMediaAsset(this.id, tmpFile, myShepherd); + ma.addLabel("matchInspectionHeatmap"); + System.out.println("[INFO] createInspectionHeatmapAsset() fetched " + fullUrl + + " and created " + ma); + tmpFile.delete(); + } catch (Exception ex) { + System.out.println( + "[ERROR] createInspectionHeatmapAsset() asset creation failed using " + fullUrl + + " => " + tmpFile + ": " + ex); + ex.printStackTrace(); + } + return ma; + } + +/* + notes on pairx payload: + - image1_uris / image2_uris accept URLs or local file paths (as seen by the server) + - If you provide 1 image1 and N image2s, it compares that single image1 against each image2 (1-to-many) + - If you provide N of each, they're compared pairwise (N-to-N, max 16 pairs) + - bb1/bb2 are bounding boxes as [x, y, width, height] + - visualization_type options: "lines_and_colors", "only_lines", "only_colors" + - layer_key controls feature depth — earlier layers (e.g. backbone.blocks.1) give point-specific matches, later layers + (e.g. backbone.blocks.5) give broader region matches + */ + public MediaAsset createInspectionPairxAsset(Annotation ann1, Annotation ann2, + Shepherd myShepherd) { + if ((ann1 == null) || (ann2 == null)) return null; + MediaAsset ma1 = ann1.getMediaAsset(); + MediaAsset ma2 = ann2.getMediaAsset(); + if ((ma1 == null) || (ma2 == null)) return null; + // we need this to find MLService endpoint + Encounter enc = ann1.findEncounter(myShepherd); + if (enc == null) return null; + JSONObject payload = new JSONObject(); + payload.put("algorithm", "pairx"); + payload.put("visualization_type", "only_colors"); + payload.put("k_colors", 5); + // payload.put("k_lines", 20); + payload.put("model_id", "miewid-msv4.1"); + payload.put("crop_bbox", false); + payload.put("layer_key", "backbone.blocks.3"); + payload.put("image1_uris", new JSONArray(new String[] { ma1.webURL().toString() })); + payload.put("image2_uris", new JSONArray(new String[] { ma2.webURL().toString() })); + payload.put("theta1", new JSONArray(new Double[] { ann1.getTheta() })); + payload.put("theta2", new JSONArray(new Double[] { ann2.getTheta() })); + // this needs an array of array(s) + JSONArray tmpArr = new JSONArray(); + tmpArr.put(0, ann1.getBbox()); + payload.put("bb1", tmpArr); + tmpArr.put(0, ann2.getBbox()); + payload.put("bb2", tmpArr); + + // get the image data from pairx endpoint + JSONObject res = null; + URL pairxUrl = null; + try { + pairxUrl = _getPairxUrl(enc.getTaxonomyString()); + if (pairxUrl == null) return null; + res = RestClient.postJSON(pairxUrl, payload, null); + } catch (Exception ex) { + System.out.println("[ERROR] createInspectionPairxAsset() POST to " + pairxUrl + + " failed: " + ex + "; payload=" + payload); + ex.printStackTrace(); + } + if (res == null) return null; + JSONArray imgs = res.optJSONArray("images"); + if ((imgs == null) || (imgs.length() < 1)) return null; + String b64 = imgs.optString(0, null); + if (b64 == null) return null; + // create the asset from base64 data + System.out.println("[DEBUG] createInspectionPairxAsset() POST to " + pairxUrl + + " got image data length=" + b64.length()); + try { + AssetStore store = AssetStore.getDefault(myShepherd); + JSONObject params = store.createParameters(new File(Util.hashDirectories(this.id) + + "/pairx-" + this.id + "-" + ann1.getId() + "-" + ann2.getId() + ".png")); + MediaAsset ma = store.create(params); + ma.copyInBase64(b64); + ma.addLabel("matchInspectionPairx"); + System.out.println("[INFO] createInspectionPairxAsset() created " + ma); + myShepherd.getPM().makePersistent(ma); + return ma; + } catch (Exception ex) { + System.out.println( + "[ERROR] createInspectionPairxAsset() failed to create MediaAsset: " + ex); + ex.printStackTrace(); + } + return null; + } + + public static URL _getPairxUrl(String txStr) + throws IOException { + if (txStr == null) throw new IOException("passed null taxonomy"); + String urlStr = null; +/* FIXME make live when merged with vectors branch + try { + MLService mls = new MLService(); + List confs = mls.getConfigs(txStr); + if (confs.size() < 1) throw new IOException("empty MLService configs for tx=" + txStr); + urlStr = confs.get(0).optString("api_endpoint", null); + } catch (IAException ex) { + throw new IOException(ex); + } + */ + if (urlStr == null) return null; + return new URL(urlStr + "/explain/"); + } + + public JSONObject getTaskParameters() { + if (task == null) return null; + return task.getParameters(); + } + + public JSONObject getTaskMatchingSetFilter() { + if (task == null) return null; + JSONObject params = task.getParameters(); + if (params == null) return null; + return params.optJSONObject("matchingSetFilter"); + } + +/* + see note at top about candidates vs numberCandidates + public int numberCandidates() { + return Util.collectionSize(candidates); + } + */ + public int numberProspects() { + return Util.collectionSize(prospects); + } + + public Set prospectScoreTypes() { + Set types = new HashSet(); + + if (numberProspects() == 0) return types; + for (MatchResultProspect mrp : prospects) { + types.add(mrp.getType()); + } + return types; + } + + // if cutoff < 0 then it will not be truncated at all + public List prospectsSorted(String type, int cutoff, + Set projectIds, Shepherd myShepherd) { + List pros = new ArrayList(); + + if (numberProspects() == 0) return pros; + for (MatchResultProspect mrp : prospects) { + if (mrp.isType(type) && mrp.isInProjects(projectIds, myShepherd)) pros.add(mrp); + } + Collections.sort(pros); + if ((cutoff > 0) && (pros.size() > cutoff)) return pros.subList(0, cutoff); + return pros; + } + + public JSONObject prospectsForApiGet(int cutoff, Set projectIds, Shepherd myShepherd) { + JSONObject sj = new JSONObject(); + + for (String type : prospectScoreTypes()) { + JSONArray jarr = new JSONArray(); + for (MatchResultProspect mrp : prospectsSorted(type, cutoff, projectIds, myShepherd)) { + jarr.put(mrp.jsonForApiGet(myShepherd)); + } + sj.put(type, jarr); + } + return sj; + } + + public JSONObject jsonForApiGet(int cutoff, Set projectIds, Shepherd myShepherd) { + JSONObject rtn = new JSONObject(); + + rtn.put("id", id); + rtn.put("queryAnnotation", annotationDetails(queryAnnotation, myShepherd)); + rtn.put("numberTotalProspects", numberProspects()); + rtn.put("numberCandidates", getNumberCandidates()); + rtn.put("created", Util.millisToISO8601String(created)); + rtn.put("prospects", prospectsForApiGet(cutoff, projectIds, myShepherd)); + rtn.put("projectIds", projectIds); + return rtn; + } + + public static JSONObject annotationDetails(Annotation ann, Shepherd myShepherd) { + JSONObject aj = new JSONObject(); + + if (ann == null) return aj; + MediaAsset ma = ann.getMediaAsset(); + // populate bounding box stuff (note: it may reset aj so must be done first) + if (ann.getFeatures() != null) { + for (Feature ft : ann.getFeatures()) { + if (ft.isUnity()) { + aj.put("trivial", true); + aj.put("x", 0); + aj.put("y", 0); + // would be weird to be null, but..... + if (ma != null) { + aj.put("width", (int)ma.getWidth()); + aj.put("height", (int)ma.getHeight()); + } + } else { + // basically if we have more than one feature, only one wins + if (ft.getParameters() != null) aj = ft.getParameters(); + } + } + } + if (ma != null) { + JSONObject mj = ma.toSimpleJSONObject(); + mj.put("rotationInfo", ma.getRotationInfo()); + aj.put("asset", mj); + } + Encounter enc = ann.findEncounter(myShepherd); + if (enc != null) { + JSONObject ej = new JSONObject(); + // TODO add "access" permission value if needed? + ej.put("id", enc.getId()); + ej.put("taxonomy", enc.getTaxonomyString()); + ej.put("locationId", enc.getLocationID()); + aj.put("encounter", ej); + MarkedIndividual indiv = enc.getIndividual(); + if (indiv != null) { + JSONObject ij = new JSONObject(); + ij.put("id", indiv.getId()); + ij.put("taxonomy", indiv.getTaxonomyString()); + ij.put("displayName", indiv.getDisplayName()); + ij.put("nickname", indiv.getNickName()); + ij.put("sex", indiv.getSex()); + ij.put("numberEncounters", indiv.getNumEncounters()); + aj.put("individual", ij); + } + } + aj.put("id", ann.getId()); + return aj; + } + + public String toString() { + String s = "MatchResult " + id; + + s += " [" + Util.millisToISO8601String(created) + "]"; + s += " query " + queryAnnotation; + s += "; numCandidates=" + this.getNumberCandidates(); + s += "; numProspects=" + this.numberProspects(); + s += "; task=" + (task == null ? "null" : task.getId()); + return s; + } +} diff --git a/src/main/java/org/ecocean/ia/MatchResultProspect.java b/src/main/java/org/ecocean/ia/MatchResultProspect.java new file mode 100644 index 0000000000..a10bee63c4 --- /dev/null +++ b/src/main/java/org/ecocean/ia/MatchResultProspect.java @@ -0,0 +1,88 @@ +package org.ecocean.ia; + +import java.util.HashSet; +import java.util.Set; + +import org.json.JSONArray; +import org.json.JSONObject; + +import org.ecocean.Annotation; +import org.ecocean.Encounter; +import org.ecocean.media.MediaAsset; +import org.ecocean.shepherd.core.Shepherd; +import org.ecocean.Util; + +public class MatchResultProspect implements java.io.Serializable, Comparable { + private Annotation annotation; + private double score = 0.0d; + private String scoreType; + private MediaAsset asset; + private MatchResult matchResult; + + public MatchResultProspect() {} + + public MatchResultProspect(Annotation ann) { + this(); + this.annotation = ann; + } + + public MatchResultProspect(Annotation ann, double score, String type, MediaAsset asset) { + this(); + this.annotation = ann; + this.score = score; + this.scoreType = type; + this.asset = asset; + } + + public double getScore() { + return score; + } + + public String getType() { + return scoreType; + } + + public boolean isType(String type) { + if (type == null) return (this.scoreType == null); + return type.equals(this.scoreType); + } + + public boolean isInProjects(Set projectIds, Shepherd myShepherd) { + // if we have no projects to filter on, we consider this to be in it + if (Util.collectionIsEmptyOrNull(projectIds)) return true; + if (annotation == null) return false; + Encounter enc = annotation.findEncounter(myShepherd); + if (enc == null) return false; + return enc.isInProjects(projectIds, myShepherd); + } + + public String toString() { + return scoreType + ": " + score + " on " + annotation; + } + + public JSONObject jsonForApiGet(Shepherd myShepherd) { + JSONObject rtn = new JSONObject(); + + rtn.put("annotation", MatchResult.annotationDetails(annotation, myShepherd)); + rtn.put("score", score); + // skipping scoreType since this is currently only used filtered by scoreType already + if (asset != null) { + JSONObject aj = asset.toSimpleJSONObject(); + aj.put("url", asset.webURL()); // we have no "safe" url + rtn.put("asset", aj); + } + return rtn; + } + + // used in sorting + @Override public int compareTo(MatchResultProspect other) { + // we invert this so higher score is first + int comp = Double.compare(other.score, this.score); + // if the scores are the same (comp == 0), we want to ensure consistent/deterministic + // ordering (otherwise tied scores come back random order), so we use annot id + if ((comp == 0) && (this.annotation != null) && (this.annotation.getId() != null) && (other.annotation != null)) + return this.annotation.getId().compareTo(other.annotation.getId()); + // scores are *not* equal, so we just let comparison stand as-is + return comp; + } +} diff --git a/src/main/java/org/ecocean/ia/Task.java b/src/main/java/org/ecocean/ia/Task.java index d840c5a561..3cc4ca9e8e 100644 --- a/src/main/java/org/ecocean/ia/Task.java +++ b/src/main/java/org/ecocean/ia/Task.java @@ -10,11 +10,14 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import javax.jdo.Query; import org.apache.commons.lang3.builder.ToStringBuilder; import org.ecocean.Annotation; +import org.ecocean.Encounter; import org.ecocean.media.MediaAsset; import org.ecocean.shepherd.core.Shepherd; +import org.ecocean.User; import org.ecocean.Util; import org.joda.time.DateTime; import org.json.JSONArray; @@ -23,6 +26,7 @@ import org.ecocean.identity.IdentityServiceLog; public class Task implements java.io.Serializable { + public static long TIMEOUT_INACTIVE_MILLIS = 7l * 24l * 60l * 60l * 1000l; private String id = null; private long created = -1; private long modified = -1; @@ -33,6 +37,8 @@ public class Task implements java.io.Serializable { private List children = null; private String parameters = null; private String status; + // general use, but notably will contain error details when status=error + private String statusDetails = null; private Long completionDateInMilliseconds; private String queueResumeMessage; @@ -63,6 +69,53 @@ public long getModifiedLong() { return modified; } + public long timeInactive() { + long now = System.currentTimeMillis(); + + if (modified > 0) return (now - modified); + if (created > 0) return (now - created); + // weird or inconclusive: + return -1l; + } + + public boolean timedOutDueToInactivity() { + return (timeInactive() > TIMEOUT_INACTIVE_MILLIS); + } + + public boolean statusInEndState() { + if ("completed".equals(status)) return true; + if ("error".equals(status)) return true; + return false; + } + + public void setModified() { + modified = System.currentTimeMillis(); + } + + public boolean canUserAccess(User user, Shepherd myShepherd) { + if (user == null) return false; + if (user.isAdmin(myShepherd)) return true; + Encounter enc = null; + // if we have annotations, use first to determine encounter + if (this.countObjectAnnotations() > 0) { + enc = this.getObjectAnnotations().get(0).findEncounter(myShepherd); + } else if (this.countObjectMediaAssets() > 0) { // no annots, use asset instead + MediaAsset ma = this.getObjectMediaAssets().get(0); + // we iterate over all annots on this asset til we find an encounter. + // it might be better to find *all* encounters and return access based on each; + // however the main use for userHasAccess() revolves around *annotation-based* tasks (matching) + // so i think this means asset-based access of tasks will be rare or unused anyway + for (Annotation ann : ma.getAnnotations()) { + if (ann != null) enc = ann.findEncounter(myShepherd); + if (enc != null) break; + } + } + if (enc == null) return false; + if (enc.isPubliclyReadable()) return true; + // note: we also have enc.canUserView() and enc.canUserEdit() !!! :( + return enc.canUserAccess(user, myShepherd.getContext()); + } + /* // not really convinced these are accurate enough to use // actual computation of these things is complicated @@ -211,6 +264,11 @@ public Task getParent() { return parent; } + public String getParentId() { + if (parent == null) return null; + return parent.getId(); + } + public int numChildren() { return (children == null) ? 0 : children.size(); } @@ -304,8 +362,49 @@ public Map identificationStatusSummary() { return cts; } + public JSONObject getStatusDetails() { + return Util.stringToJSONObject(statusDetails); + } + + public void setStatusDetails(String s) { + statusDetails = s; + } + + public void setStatusDetails(JSONObject j) { + if (j == null) { + statusDetails = null; + } else { + statusDetails = j.toString(); + } + } + + public void setStatusDetailsAddError(String code, String message) { + JSONObject add = new JSONObject(); + + add.put("code", code); + add.put("message", message); + setStatusDetailsAddToSection("errors", add); + } + + public void setStatusDetailsAddLog(String message) { + JSONObject add = new JSONObject(); + + add.put("message", message); + setStatusDetailsAddToSection("log", add); + } + + // internal utility method for above + private void setStatusDetailsAddToSection(String section, JSONObject add) { + if (add == null) return; + add.put("timestamp", System.currentTimeMillis()); + JSONObject sd = getStatusDetails(); + if (sd == null) sd = new JSONObject(); + if (sd.optJSONArray(section) == null) sd.put(section, new JSONArray()); + sd.getJSONArray(section).put(add); + setStatusDetails(sd); + } + public JSONObject getParameters() { // only return as JSONObject! - if (parameters == null) return null; return Util.stringToJSONObject(parameters); } @@ -484,9 +583,19 @@ public boolean areSelfAndOrAllChildrenComplete() { } public String getStatus(Shepherd myShepherd) { + // see if we might be dead in the water + // TODO skipping status==null cuz i cant figure out what this means and there are so many of them + if (!statusInEndState() && timedOutDueToInactivity() && !(this.status == null)) { + this.status = "error"; + long ti = timeInactive(); + setStatusDetailsAddError("TIMEOUT", + "this task is likely timed out; no activity for " + Util.millisToHumanApprox(ti)); + return this.status; + } // if status is not null, just send it if (status != null) return status; // otherwise + // note: this is LOCAL status :( so it is not changing this.status, only returning the value String status = "waiting to queue"; ArrayList logs = IdentityServiceLog.loadByTaskID(getId(), "IBEISIA", myShepherd); @@ -513,6 +622,8 @@ public String getStatus(Shepherd myShepherd) { // if(islObj.optString("queueStatus").equals("queued")){sendIdentify=false;} // if(status.equals("waiting to queue"))System.out.println("islObj: "+islObj.toString()); } + System.out.println("[DEBUG] getStatus() fell through to status='" + status + "' on Task " + + this.getId()); return status; } @@ -574,11 +685,12 @@ public boolean isFastlane(Shepherd myShepherd) { } public void setStatus(String newStatus) { + setModified(); if (newStatus == null) status = null; else { status = newStatus; } } - public java.lang.Long getCompletionDateInMilliseconds() { return completionDateInMilliseconds; } + public Long getCompletionDateInMilliseconds() { return completionDateInMilliseconds; } // this will set all date stuff based on ms since epoch public void setCompletionDateInMilliseconds(Long ms) { @@ -594,4 +706,153 @@ public void setQueueResumeMessage(String message) { queueResumeMessage = message; } } + + public JSONObject getMatchingSetFilter() { + if (getParameters() == null) return null; + return getParameters().optJSONObject("matchingSetFilter"); + } + + public JSONObject getIdentificationMethodInfo() { + if (getParameters() == null) return null; + if (getParameters().optJSONObject("ibeis.identification") == null) return null; + JSONObject rtn = new JSONObject(); + // vector/embed flavor + if (getParameters().getJSONObject("ibeis.identification").optString("api_endpoint", + null) != null) { + String modelId = getParameters().getJSONObject("ibeis.identification").optString( + "model_id", null); + if (modelId == null) { + rtn.put("description", "Vector embedding match"); + } else { + rtn.put("description", "Vector embedding match (model: " + modelId + ")"); + rtn.put("modelId", modelId); + } + return rtn; + } + // it seems both of these are in most logs (and are identical), but being safe in case there are + // examples in the wild with only one + JSONObject conf = getParameters().getJSONObject("ibeis.identification").optJSONObject( + "query_config_dict"); + if (conf == null) + conf = getParameters().getJSONObject("ibeis.identification").optJSONObject( + "queryConfigDict"); + // we set HotSpotter if pipeline_root is not set here + if (conf != null) rtn.put("name", conf.optString("pipeline_root", "HotSpotter")); + rtn.put("description", + getParameters().getJSONObject("ibeis.identification").optString("description", + "unknown algorithm/method")); + return rtn; + } + + // convenience + public List getMatchResults(Shepherd myShepherd) { + return myShepherd.getMatchResults(this); + } + + public MatchResult getLatestMatchResult(Shepherd myShepherd) { + List all = myShepherd.getMatchResults(this); + + if (Util.collectionIsEmptyOrNull(all)) return null; + return all.get(0); + } + + // logs are returned in chronological order here, so if the latest is desired, take the LAST one + public List generateMatchResults(Shepherd myShepherd) { + List mrs = new ArrayList(); + ArrayList logs = IdentityServiceLog.loadByTaskID(this.id, "IBEISIA", + myShepherd); + + if (logs == null) return mrs; + for (IdentityServiceLog log : logs) { + JSONObject res = log.getJsonResult(); + // in theory this is how we can tell if it is an ident result log versus detection + if ((res != null) && (res.optJSONObject("cm_dict") != null)) { + try { + MatchResult mr = new MatchResult(log, myShepherd); + System.out.println("[INFO] generateMatchResults() [log t=" + + log.getTimestamp() + "] on Task " + this.getId() + " generated: " + mr); + myShepherd.getPM().makePersistent(mr); + mrs.add(mr); + setStatusDetailsAddLog("Created " + mr + " from IdentityServiceLog " + + log.getTimestamp()); + } catch (java.io.IOException ex) { + System.out.println("[ERROR] generateMatchResults() [log t=" + + log.getTimestamp() + "] on Task " + this.getId() + " failed: " + ex); + ex.printStackTrace(); + setStatusDetailsAddError("UNKNOWN", + "Creation of MatchResult from IdentityServiceLog " + log.getTimestamp() + + " failed due to: " + ex); + } + } + } + return mrs; + } + + public JSONObject matchResultsJson(int cutoff, Set projectIds, Shepherd myShepherd) { + JSONObject rtn = new JSONObject(); + + rtn.put("id", getId()); + rtn.put("parentTaskId", getParentId()); + rtn.put("dateCreated", Util.millisToISO8601String(getCreatedLong())); + rtn.put("dateCompleted", Util.millisToISO8601String(getCompletionDateInMilliseconds())); + rtn.put("timeInactiveMillis", timeInactive()); + // TODO theory is that we might not need to use/store queryAnnotation on MatchResult as + // we should have it here, hence this debugging value ... possible optimization for later + if (hasObjectAnnotations()) { + JSONArray annotArr = new JSONArray(); + for (Annotation ann : getObjectAnnotations()) { + if (ann != null) annotArr.put(ann.getId()); + } + rtn.put("__taskAnnotations", annotArr); + } + JSONObject methodInfo = getIdentificationMethodInfo(); + // we basically use this to determine if we are "identification-like" enough + // to display extended details + if (methodInfo != null) { + rtn.put("method", methodInfo); + rtn.put("matchingSetFilter", getMatchingSetFilter()); +/* + 1. we only care about (and importantly try to generate) MatchResults for ident type *with no children* + (as there may be non-leaf nodes with methodInfo) + * note: we try getting it regardless of children ("just in case"); but only try to generate if none + 2. getLatestMatchResult() and generateMatchResults() only pertain to log-based (wbia) results, + as vector results should have generated their MatchResult upon completion + */ + MatchResult mr = getLatestMatchResult(myShepherd); + if ((mr == null) && !hasChildren()) { + System.out.println( + "[DEBUG] matchResultsJson() found no MatchResults; generating on (leaf) Task " + + this.getId()); + List mrs = generateMatchResults(myShepherd); + rtn.put("_generatedMatchResultsSize", mrs.size()); // leave a clue that we did the work! + if (mrs.size() > 0) { + mr = mrs.get(mrs.size() - 1); + // this hack is important cuz it forces a db commit even though we are a GET api call sorrynotsorry + rtn.put("_commitShepherd", true); + } + } + if (mr != null) + rtn.put("matchResults", mr.jsonForApiGet(cutoff, projectIds, myShepherd)); + } + // now we recurse thru children if applicable + if (hasChildren()) { + JSONArray charr = new JSONArray(); + for (Task child : children) { + // TODO decide if we need to process child???? + JSONObject childJson = child.matchResultsJson(cutoff, projectIds, myShepherd); + // we have to bubble this up all the way to the toplevel :/ + if (childJson.optBoolean("_commitShepherd", false)) + rtn.put("_commitShepherd", true); + charr.put(childJson); + } + rtn.put("children", charr); + // if we dont have children (leaf nodes) we get the status + } else { + // unsure which of these two things is more accurate or useful; thus including both + rtn.put("status", getStatus(myShepherd)); + rtn.put("statusOverall", getOverallStatus(myShepherd)); + rtn.put("statusDetails", getStatusDetails()); + } + return rtn; + } } diff --git a/src/main/java/org/ecocean/identity/IBEISIA.java b/src/main/java/org/ecocean/identity/IBEISIA.java index 43ae4650d8..ff509d48e9 100644 --- a/src/main/java/org/ecocean/identity/IBEISIA.java +++ b/src/main/java/org/ecocean/identity/IBEISIA.java @@ -60,7 +60,6 @@ // date time - public class IBEISIA { // move this ish to its own class asap! private static final Map speciesMap; @@ -412,7 +411,7 @@ public static JSONArray imageUUIDList(List mas) { JSONArray uuidList = new JSONArray(); for (MediaAsset ma : mas) { - if(ma.getAcmId()!=null)uuidList.put(toFancyUUID(ma.getAcmId())); + if (ma.getAcmId() != null) uuidList.put(toFancyUUID(ma.getAcmId())); } return uuidList; } @@ -723,6 +722,14 @@ public static JSONObject getTaskResultsBasic(String taskID, return null; // if we fall through, it means we are still waiting ...... } + // singular log version + public static JSONObject getTaskResultsBasic(String taskID, IdentityServiceLog log) { + ArrayList one = new ArrayList(); + + one.add(log); + return getTaskResultsBasic(taskID, one); + } + public static HashMap getTaskResultsAsHashMap(String taskID, String context) { JSONObject jres = getTaskResults(taskID, context); HashMap res = new HashMap(); @@ -1485,8 +1492,7 @@ public static JSONObject processCallback(String taskID, JSONObject resp, String subParentTask.setParameters(taskParameters); myShepherd2.storeNewTask(subParentTask); myShepherd2.updateDBTransaction(); - - + Task childTask = IA.intakeAnnotations(myShepherd2, annots, subParentTask, false); myShepherd2.storeNewTask(childTask); @@ -1566,7 +1572,8 @@ private static JSONObject processCallbackDetect(String taskID, MediaAsset asset = null; for (MediaAsset ma : mas) { if (ma.getAcmId() == null) continue; // was likely an asset rejected (e.g. video) - if (ma.getAcmId().equals(iuuid) && !alreadyDetected.contains(ma.getIdInt())) { + if (ma.getAcmId().equals(iuuid) && + !alreadyDetected.contains(ma.getIdInt())) { alreadyDetected.add(ma.getIdInt()); asset = ma; break; @@ -1758,6 +1765,7 @@ private static JSONObject processCallbackIdentify(String taskID, // set "error" on Task Task task = myShepherd.getTask(taskID); if (task != null) { + task.setStatusDetailsAddError("INVALID", "could not parse inference_dict from results"); task.setStatus("error"); } myShepherd.rollbackDBTransaction(); @@ -1829,6 +1837,17 @@ private static JSONObject processCallbackIdentify(String taskID, if (task != null) { task.setStatus("completed"); task.setCompletionDateInMilliseconds(Long.valueOf(System.currentTimeMillis())); + try { + MatchResult mr = new MatchResult(task, j, myShepherd); + System.out.println("processCallbackIdentify() created " + mr + " on " + task); + myShepherd.getPM().makePersistent(mr); + task.setStatusDetailsAddLog("Created " + mr + " upon task completion"); + } catch (IOException ex) { + System.out.println("processCallbackIdentify() failed to create MatchResult on " + + task + ": " + ex); + ex.printStackTrace(); + task.setStatusDetailsAddError("UNKNOWN", "Creation of MatchResult upon task completion failed due to: " + ex); + } } myShepherd.commitDBTransaction(); myShepherd.closeDBTransaction(); @@ -2015,9 +2034,6 @@ public static URL iaURL(String context, String urlSuffix) { System.out.println("INFO: setting iaBaseURL=" + iaBaseURL); } String ustr = iaBaseURL; - - System.out.println("!!!ustr: " + iaBaseURL); - System.out.println("!!!urlSuffix: " + urlSuffix); if (urlSuffix != null) { if (urlSuffix.indexOf("/") == 0) urlSuffix = urlSuffix.substring(1); // get rid of leading / ustr += urlSuffix; diff --git a/src/main/java/org/ecocean/identity/IdentityServiceLog.java b/src/main/java/org/ecocean/identity/IdentityServiceLog.java index 683eaf3764..3983afdb56 100644 --- a/src/main/java/org/ecocean/identity/IdentityServiceLog.java +++ b/src/main/java/org/ecocean/identity/IdentityServiceLog.java @@ -294,6 +294,16 @@ public static void save(IdentityServiceLog l, Shepherd myShepherd) { myShepherd.getPM().makePersistent(l); } */ + public JSONObject getJsonResult() { + JSONObject status = getStatusJson(); + + if (status == null) return null; + if (status.optJSONObject("_response") == null) return null; + if (status.getJSONObject("_response").optJSONObject("response") == null) return null; + return status.getJSONObject("_response").getJSONObject("response").optJSONObject( + "json_result"); + } + public JSONObject toJSONObject() { return toJSONObject(false); } diff --git a/src/main/java/org/ecocean/media/MediaAsset.java b/src/main/java/org/ecocean/media/MediaAsset.java index f20e278cc4..b98c7c96bc 100644 --- a/src/main/java/org/ecocean/media/MediaAsset.java +++ b/src/main/java/org/ecocean/media/MediaAsset.java @@ -970,7 +970,7 @@ public JSONArray getKeywordsJSONArray() { public JSONObject toSimpleJSONObject() { JSONObject j = new JSONObject(); - j.put("id", getId()); + j.put("id", getIdInt()); j.put("uuid", getUUID()); j.put("url", safeURL()); if ((getMetadata() != null) && (getMetadata().getData() != null) && diff --git a/src/main/java/org/ecocean/servlet/IAGateway.java b/src/main/java/org/ecocean/servlet/IAGateway.java index fdfbc8798b..03b7020ac3 100644 --- a/src/main/java/org/ecocean/servlet/IAGateway.java +++ b/src/main/java/org/ecocean/servlet/IAGateway.java @@ -474,6 +474,10 @@ private static JSONObject _sendIdentificationTask(Annotation ann, String context sent.put("_action", "error"); IBEISIA.log(annTaskId, ann.getId(), jobId, sent, context); taskRes.put("error", sent.optJSONObject("error")); + task.setStatus("error"); + task.setStatusDetailsAddError("UNKNOWN", "ident task failed to send: " + + ((sent.optJSONObject("error") == null) ? "unknown reason" : sent.getJSONObject("error").toString())); + task.setCompletionDateInMilliseconds(Long.valueOf(System.currentTimeMillis())); } } catch (Exception ex) { success = false; diff --git a/src/main/java/org/ecocean/servlet/importer/ImportTask.java b/src/main/java/org/ecocean/servlet/importer/ImportTask.java index e84d2d8191..73906e0b10 100644 --- a/src/main/java/org/ecocean/servlet/importer/ImportTask.java +++ b/src/main/java/org/ecocean/servlet/importer/ImportTask.java @@ -403,6 +403,7 @@ public JSONObject statsAnnotations(Shepherd myShepherd) { // this records only most recent task statuses like: numLatestTask_complete if (latestTask) { String latestStatus = "numLatestTask_" + atask.getStatus(myShepherd); + System.out.println("[DEBUG] (ImportTask " + this.getId() + ") latestStatus for Task " + atask.getId() + ": " + latestStatus); if (sa.has(latestStatus)) { sa.put(latestStatus, sa.optInt(latestStatus, 0) + 1); } else { diff --git a/src/main/java/org/ecocean/shepherd/core/Shepherd.java b/src/main/java/org/ecocean/shepherd/core/Shepherd.java index a5c250703a..3b040fa7cf 100644 --- a/src/main/java/org/ecocean/shepherd/core/Shepherd.java +++ b/src/main/java/org/ecocean/shepherd/core/Shepherd.java @@ -16,6 +16,7 @@ import org.ecocean.genetics.*; import org.ecocean.grid.ScanTask; import org.ecocean.grid.ScanWorkItem; +import org.ecocean.ia.MatchResult; import org.ecocean.ia.Task; import org.ecocean.media.*; import org.ecocean.movement.Path; @@ -2802,6 +2803,32 @@ public List getIdentificationTasksForUser(User user) { return all; } + public MatchResult getMatchResult(String id) { + MatchResult mr = null; + + try { + mr = (MatchResult)(pm.getObjectById(pm.newObjectIdInstance(MatchResult.class, id), + true)); + } catch (Exception ex) { + ex.printStackTrace(); + } + return mr; + } + + public List getMatchResults(Task task) { + List all = new ArrayList(); + + if (task == null) return all; + String filter = "SELECT FROM org.ecocean.ia.MatchResult WHERE task.id == '" + task.getId() + + "'"; + Query query = pm.newQuery(filter); + query.setOrdering("created DESC"); + Collection c = (Collection)query.execute(); + if (c != null) all = new ArrayList(c); + query.closeAll(); + return all; + } + public MarkedIndividual getMarkedIndividualQuiet(String name) { MarkedIndividual indiv = null; diff --git a/src/main/resources/org/ecocean/ia/package.jdo b/src/main/resources/org/ecocean/ia/package.jdo index cca74a32f5..b47b49b153 100755 --- a/src/main/resources/org/ecocean/ia/package.jdo +++ b/src/main/resources/org/ecocean/ia/package.jdo @@ -32,6 +32,10 @@ alter table "TASK" alter column "PARAMETERS" type text; + + + + @@ -68,5 +72,42 @@ alter table "TASK" alter column "PARAMETERS" type text; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index 728d6cf1ed..58bc9b5fac 100755 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -424,6 +424,10 @@ ApiBaseObject /api/v3/encounters/* + + ApiGenericObject + /api/v3/tasks/* + ApiBaseObject diff --git a/src/main/webapp/encounters/biologicalSamples.jsp b/src/main/webapp/encounters/biologicalSamples.jsp index 9f1b3a8ca6..7a61a89849 100644 --- a/src/main/webapp/encounters/biologicalSamples.jsp +++ b/src/main/webapp/encounters/biologicalSamples.jsp @@ -1946,7 +1946,7 @@ $('.ia-match-filter-dialog input').each(function(i, el) { console.log('SENDING ===> %o', data); wildbook.IA.getPluginByType('IBEIS').restCall(data, function(xhr, textStatus) { console.log('RETURNED ========> %o %o', textStatus, xhr.responseJSON.taskId); - wildbook.openInTab('../iaResults.jsp?taskId=' + xhr.responseJSON.taskId); + wildbook.openInTab('../react/match-results?taskId=' + xhr.responseJSON.taskId); }); iaMatchFilterAnnotationIds = []; //clear it out in case user sends again from this page $('.ia-match-filter-dialog').hide(); diff --git a/src/main/webapp/iaResults.jsp b/src/main/webapp/iaResults.jsp index 42034865c4..1cc083c8f9 100755 --- a/src/main/webapp/iaResults.jsp +++ b/src/main/webapp/iaResults.jsp @@ -2009,7 +2009,7 @@ function isProjectSelected() { $('#projectDropdown').on('change', function() { let taskId = '<%=taskId%>'; - let reloadURL = "../iaResults.jsp?taskId="+taskId; + let reloadURL = "../react/match-results?taskId="+taskId; let selectedProject = $("#projectDropdown").val(); // replace reserved pound sign in incremental ID's selectedProject = selectedProject.replaceAll("#", "%23"); diff --git a/src/main/webapp/javascript/ia.IBEIS.js b/src/main/webapp/javascript/ia.IBEIS.js index 5da34479bf..df77afa612 100644 --- a/src/main/webapp/javascript/ia.IBEIS.js +++ b/src/main/webapp/javascript/ia.IBEIS.js @@ -101,7 +101,7 @@ console.log('_iaMenuHelper: mode=%o, mid=%o, aid=%o, ma=%o, iaStatus=%o, identAc return 'no matchable detection'; } else if (mode == 'funcStart') { //registerTaskId(iaStatus.taskId); - //wildbook.openInTab('../iaResults.jsp?taskId=' + iaStatus.taskId); + //wildbook.openInTab('../react/match-results?taskId=' + iaStatus.taskId); return; } // allow results page only if detection is complete or there is a verifiable identification status @@ -110,7 +110,7 @@ console.log('_iaMenuHelper: mode=%o, mid=%o, aid=%o, ma=%o, iaStatus=%o, identAc return 'match results'; } else if (mode == 'funcStart') { registerTaskId(iaStatus.taskId); - wildbook.openInTab('../iaResults.jsp?taskId=' + iaStatus.taskId); + wildbook.openInTab('../react/match-results?taskId=' + iaStatus.taskId); return; } } diff --git a/src/main/webapp/projects/project.jsp b/src/main/webapp/projects/project.jsp index d20f5a80aa..b9c27526d1 100644 --- a/src/main/webapp/projects/project.jsp +++ b/src/main/webapp/projects/project.jsp @@ -550,7 +550,7 @@ function removeEncounterFromProjectAjax(el) { function goToIAResults(taskId) { let projectIdPrefix = '<%= project.getProjectIdPrefix()%>'; - window.open('/iaResults.jsp?taskId='+taskId+'&projectIdPrefix='+encodeURIComponent(projIdPrefix), "_blank"); + window.open('/react/match-results?taskId='+taskId+'&projectIdPrefix='+encodeURIComponent(projIdPrefix), "_blank"); } function generateIALinkingMenu(json, encId) { diff --git a/src/test/java/org/ecocean/MatchResultTest.java b/src/test/java/org/ecocean/MatchResultTest.java new file mode 100644 index 0000000000..ff1153e1a7 --- /dev/null +++ b/src/test/java/org/ecocean/MatchResultTest.java @@ -0,0 +1,112 @@ +package org.ecocean; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.ecocean.Annotation; +import org.ecocean.ia.MatchResult; +import org.ecocean.ia.MatchResultProspect; +import org.ecocean.ia.Task; +import org.ecocean.media.MediaAsset; +import org.ecocean.shepherd.core.Shepherd; +import org.ecocean.shepherd.core.ShepherdPMF; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.jupiter.api.Test; + +import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import javax.jdo.PersistenceManager; +import javax.jdo.PersistenceManagerFactory; + +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +class MatchResultTest { + @Test void testMatchResultClassic() + throws IOException { + Task task = mock(Task.class); + MatchResult mr = new MatchResult(task); + + assertTrue(mr.getNumberCandidates() == 0); + + Annotation ann = mock(Annotation.class); + ArrayList annList = new ArrayList(); + annList.add(ann); + + Shepherd myShepherd = mock(Shepherd.class); + when(myShepherd.getAnnotationsWithACMId(any(String.class), + any(Boolean.class))).thenReturn(annList); + + // gotta build whole IA json structure here :( + JSONObject res = new JSONObject(); + res.put("query_annot_uuid_list", new JSONArray("[{\"__UUID__\": \"query-annot-id\"}]")); + res.put("database_annot_uuid_list", + new JSONArray( + "[{\"__UUID__\": \"id0\"}, {\"__UUID__\": \"id1\"}, {\"__UUID__\": \"id2\"}]")); + JSONObject cm_dict = new JSONObject(); + JSONObject lists = new JSONObject(); + lists.put("dannot_uuid_list", + new JSONArray("[{\"__UUID__\": \"id0\"}, {\"__UUID__\": \"id1\"}]")); + lists.put("annot_score_list", new JSONArray("[0.1, 0.2]")); + lists.put("score_list", new JSONArray("[0.3, 0.4]")); + cm_dict.put("query-annot-id", lists); + res.put("cm_dict", cm_dict); + mr.createFromJsonResult(res, myShepherd); + assertTrue(mr.getNumberCandidates() == 3); + assertTrue(mr.numberProspects() == 4); + assertTrue(mr.prospectScoreTypes().contains("indiv")); + assertTrue(mr.prospectScoreTypes().contains("annot")); + JSONObject pj = mr.prospectsForApiGet(-1, null, myShepherd); + // verify ordering is correct + assertTrue(pj.getJSONArray("indiv").getJSONObject(0).getDouble("score") == 0.4); + assertTrue(pj.getJSONArray("indiv").getJSONObject(1).getDouble("score") == 0.3); + assertTrue(pj.getJSONArray("annot").getJSONObject(0).getDouble("score") == 0.2); + assertTrue(pj.getJSONArray("annot").getJSONObject(1).getDouble("score") == 0.1); + JSONObject full = mr.jsonForApiGet(-1, null, myShepherd); + assertTrue(full.getInt("numberTotalProspects") == 4); + assertTrue(full.getInt("numberCandidates") == 3); + } + + // annotation-list style creation + @Test void testMatchResultVector() + throws IOException { + Task task = mock(Task.class); + + when(task.countObjectAnnotations()).thenReturn(1); + int numCand = 99; + Annotation ann = mock(Annotation.class); + ArrayList annList = new ArrayList(); + + annList.add(ann); + when(task.getObjectAnnotations()).thenReturn(annList); + + MatchResult mr = new MatchResult(task, annList, numCand, null); + assertTrue(mr.getNumberCandidates() == numCand); + assertTrue(mr.numberProspects() == 1); + // FIXME someday we need to figure out indiv-vector-search + // assertTrue(mr.prospectScoreTypes().contains("indiv")); + assertTrue(mr.prospectScoreTypes().contains("annot")); + } + + @Test void basicMatchResultProspect() { + MatchResultProspect mrp = new MatchResultProspect(null, 1.0, "test", null); + + assertNotNull(mrp); + assertTrue(mrp.getScore() == 1.0); + assertEquals(mrp.getType(), "test"); + assertTrue(mrp.isType("test")); + assertFalse(mrp.isType(null)); + // null annotation allows us to get away with null shepherd passed here + // as annotationDetails() will simply return empty json for no annot + JSONObject json = mrp.jsonForApiGet(null); + assertTrue(json.getDouble("score") == 1.0); + } +} diff --git a/src/test/java/org/ecocean/UtilTest.java b/src/test/java/org/ecocean/UtilTest.java index 533b134799..2a80d7c0a0 100644 --- a/src/test/java/org/ecocean/UtilTest.java +++ b/src/test/java/org/ecocean/UtilTest.java @@ -45,6 +45,19 @@ class UtilTest { assertTrue(Util.dateIsInFuture(year + 1, month, day)); } + @Test void testHumanApprox() { + Long ms = 1003L; + assertEquals("1 second", Util.millisToHumanApprox(ms)); + ms = 21100L; + assertEquals("21 seconds", Util.millisToHumanApprox(ms)); + ms = 120333L; + assertEquals("2 minutes", Util.millisToHumanApprox(ms)); + ms = 11L * 60L * 60L * 1000L; + assertEquals("11 hours", Util.millisToHumanApprox(ms)); + ms = 191L * 24L * 60L * 60L * 1000L; + assertEquals("191 days", Util.millisToHumanApprox(ms)); + } + // some of these assertions may fail if the world collapses // into political chaos @Test void testCountries() { diff --git a/src/test/java/org/ecocean/api/TaskMatchResults.java b/src/test/java/org/ecocean/api/TaskMatchResults.java new file mode 100644 index 0000000000..b76d6d2fa1 --- /dev/null +++ b/src/test/java/org/ecocean/api/TaskMatchResults.java @@ -0,0 +1,173 @@ +package org.ecocean.api; + +import javax.jdo.PersistenceManager; +import javax.jdo.PersistenceManagerFactory; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.ServletException; + +import org.ecocean.ia.Task; + +import org.ecocean.api.GenericObject; +// import org.ecocean.CommonConfiguration; +import org.ecocean.shepherd.core.Shepherd; +import org.ecocean.shepherd.core.ShepherdPMF; +import org.ecocean.User; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringReader; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class TaskMatchResults { + PersistenceManagerFactory mockPMF; + HttpServletRequest mockRequest; + HttpServletResponse mockResponse; + GenericObject apiServlet; + StringWriter responseOut; + + @BeforeEach void setUp() + throws IOException { + mockRequest = mock(HttpServletRequest.class); + mockResponse = mock(HttpServletResponse.class); + mockPMF = mock(PersistenceManagerFactory.class); + apiServlet = new GenericObject(); + + responseOut = new StringWriter(); + PrintWriter writer = new PrintWriter(responseOut); + when(mockResponse.getWriter()).thenReturn(writer); + +/* + try (MockedConstruction mockShepherd = mockConstruction(Shepherd.class, + (mock, context) -> { + when(mock.getUser(any(HttpServletRequest.class))).thenThrow(new RuntimeException("ohmgee")); + })) { + mockRequest = mock(HttpServletRequest.class); + mockResponse = mock(HttpServletResponse.class); + apiServlet = new SiteSettings(); + } + */ + } + + @Test void apiGet401() + throws ServletException, IOException { + when(mockRequest.getRequestURI()).thenReturn("/api/v3/tasks"); + try (MockedConstruction mockShepherd = mockConstruction(Shepherd.class, + (mock, context) -> { + doNothing().when(mock).beginDBTransaction(); + })) { + try (MockedStatic mockService = mockStatic(ShepherdPMF.class)) { + mockService.when(() -> ShepherdPMF.getPMF(any(String.class))).thenReturn(mockPMF); + apiServlet.doGet(mockRequest, mockResponse); + responseOut.flush(); + JSONObject jout = new JSONObject(responseOut.toString()); + verify(mockResponse).setStatus(401); + assertFalse(jout.getBoolean("success")); + } + } + } + + // basically tests api path without /match-results + @Test void apiGetInvalidOperation() + throws ServletException, IOException { + User user = mock(User.class); + String id = "some-id"; + + when(mockRequest.getRequestURI()).thenReturn("/api/v3/tasks/" + id); + when(user.isAdmin(any(Shepherd.class))).thenReturn(false); + + try (MockedConstruction mockShepherd = mockConstruction(Shepherd.class, + (mock, context) -> { + when(mock.getUser(any(HttpServletRequest.class))).thenReturn(user); + when(mock.getTask(any(String.class))).thenReturn(null); + })) { + try (MockedStatic mockService = mockStatic(ShepherdPMF.class)) { + mockService.when(() -> ShepherdPMF.getPMF(any(String.class))).thenReturn(mockPMF); + apiServlet.doGet(mockRequest, mockResponse); + responseOut.flush(); + JSONObject jout = new JSONObject(responseOut.toString()); + verify(mockResponse).setStatus(400); + assertFalse(jout.getBoolean("success")); + assertTrue(jout.getString("debug").contains("invalid tasks operation")); + } + } + } + + @Test void apiGet404() + throws ServletException, IOException { + User user = mock(User.class); + String id = "404-id"; + + when(mockRequest.getRequestURI()).thenReturn("/api/v3/tasks/" + id + "/match-results"); + when(user.isAdmin(any(Shepherd.class))).thenReturn(false); + + try (MockedConstruction mockShepherd = mockConstruction(Shepherd.class, + (mock, context) -> { + when(mock.getUser(any(HttpServletRequest.class))).thenReturn(user); + when(mock.getTask(any(String.class))).thenReturn(null); + })) { + try (MockedStatic mockService = mockStatic(ShepherdPMF.class)) { + mockService.when(() -> ShepherdPMF.getPMF(any(String.class))).thenReturn(mockPMF); + apiServlet.doGet(mockRequest, mockResponse); + responseOut.flush(); + JSONObject jout = new JSONObject(responseOut.toString()); + verify(mockResponse).setStatus(404); + assertFalse(jout.getBoolean("success")); + } + } + } + + @Test void apiGetSuccess() + throws ServletException, IOException { + User user = mock(User.class); + + // this doesnt really test the output value of "matchResultsRoot", since we + // test task.matchResultsJson() seperately so dont care about the null value here + Task task = mock(Task.class); + String id = "ok-id"; + + when(mockRequest.getRequestURI()).thenReturn("/api/v3/tasks/" + id + "/match-results"); + when(user.isAdmin(any(Shepherd.class))).thenReturn(false); + + try (MockedConstruction mockShepherd = mockConstruction(Shepherd.class, + (mock, context) -> { + when(mock.getUser(any(HttpServletRequest.class))).thenReturn(user); + when(mock.getTask(any(String.class))).thenReturn(task); + })) { + try (MockedStatic mockService = mockStatic(ShepherdPMF.class)) { + mockService.when(() -> ShepherdPMF.getPMF(any(String.class))).thenReturn(mockPMF); + apiServlet.doGet(mockRequest, mockResponse); + responseOut.flush(); + JSONObject jout = new JSONObject(responseOut.toString()); + verify(mockResponse).setStatus(200); + assertTrue(jout.getBoolean("success")); + } + } + } +}