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 (
+
+
+

+
+ {!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)}
+ />
+ )}
+
-
-
+
+ {store?.siteSettingsLoading ? (
+
+ Loading algorithms...
+
+ ) : (
+
{
- 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 && (
+
+ )}
+
+
+
+
+
+
+ {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}
+
+
+
+
+
+
+
+
+
+
+ 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 (
+
+
+
+ );
+}
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 || "-"}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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 = (
+
+ );
+
+ 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: (
+
+ ),
+ 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: (
+
+ ),
+ 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: (
+
+ ),
+ 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"));
+ }
+ }
+ }
+}