Skip to content

Commit 2f146bf

Browse files
committed
Merge branch 'main' into mantamatcher.org
# Conflicts: # src/main/resources/emails/en/passwordReset.html # src/main/webapp/images/favicon.ico # src/main/webapp/whoAreWe.jsp
2 parents ccd6130 + 1cb991a commit 2f146bf

137 files changed

Lines changed: 17769 additions & 1207 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

frontend/package-lock.json

Lines changed: 318 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,19 @@
2323
"react": "^18.2.0",
2424
"react-bootstrap": "^2.10.1",
2525
"react-burger-menu": "^3.0.9",
26+
"react-circular-progressbar": "^2.2.0",
2627
"react-data-table-component": "^7.6.2",
2728
"react-datetime": "^3.2.0",
2829
"react-dom": "^18.2.0",
29-
"react-hook-form": "^7.52.1",
30+
"react-icons": "^4.12.0",
3031
"react-intl": "^6.6.2",
3132
"react-konva": "^18.2.10",
3233
"react-paginate": "^8.2.0",
3334
"react-router-bootstrap": "^0.26.2",
3435
"react-router-dom": "^6.22.0",
3536
"react-scripts": "5.0.1",
3637
"react-select": "^5.8.0",
38+
"react-window": "^1.8.11",
3739
"sass": "^1.71.1",
3840
"web-vitals": "^2.1.4",
3941
"webpack-merge": "^5.10.0",
@@ -80,6 +82,7 @@
8082
"devDependencies": {
8183
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
8284
"@eslint/js": "^9.9.1",
85+
"@tanstack/react-table": "^8.21.3",
8386
"@testing-library/jest-dom": "^6.5.0",
8487
"@testing-library/react": "^16.0.1",
8588
"bootstrap-icons": "^1.11.3",
@@ -93,15 +96,17 @@
9396
"jest": "^27.5.1",
9497
"react-app-rewired": "^2.2.1",
9598
"react-bootstrap-icons": "^1.11.3",
99+
"react-hook-form": "^7.55.0",
96100
"react-query": "^3.39.3",
97101
"style-loader": "^3.3.4",
98102
"styled-components": "^6.1.8",
99103
"terser-webpack-plugin": "^5.3.10",
104+
"ts-jest": "^27.0.7",
100105
"unused-webpack-plugin": "^2.4.0",
101106
"webpack": "^5.94.0",
102107
"webpack-bundle-analyzer": "^4.10.1",
103108
"webpack-cli": "^5.1.4",
104109
"webpack-dev-server": "^4.15.1",
105-
"ts-jest":"^27.0.7"
110+
"xlsx": "^0.18.5"
106111
}
107112
}

frontend/src/AuthenticatedSwitch.jsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, { lazy, Suspense } from "react";
2+
23
import { Routes, Route } from "react-router-dom";
34
import NotFound from "./pages/errorPages/NotFound";
45
import AuthenticatedAppHeader from "./components/AuthenticatedAppHeader";
@@ -23,6 +24,9 @@ const ReportConfirm = lazy(
2324
const ProjectList = lazy(() => import("./pages/ProjectList"));
2425
const ManualAnnotation = lazy(() => import("./pages/ManualAnnotation"));
2526

27+
const BulkImport = lazy(() => import("./pages/BulkImport/BulkImport"));
28+
const BulkImportTask = lazy(() => import("./pages/BulkImport/BulkImportTask"));
29+
2630
export default function AuthenticatedSwitch({
2731
showclassicsubmit,
2832
showClassicEncounterSearch,
@@ -74,6 +78,8 @@ export default function AuthenticatedSwitch({
7478
<Route path="/encounter-search" element={<EncounterSearch />} />
7579
<Route path="/admin/logs" element={<AdminLogs />} />
7680
<Route path="/manual-annotation" element={<ManualAnnotation />} />
81+
<Route path="/bulk-import" element={<BulkImport />} />
82+
<Route path="/bulk-import-task" element={<BulkImportTask />} />
7783
<Route path="/login" element={<Login />} />
7884
<Route path="/" element={<Home />} />
7985
<Route path="*" element={<NotFound setHeader={setHeader} />} />

frontend/src/FrontDesk.jsx

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,20 @@ export default function FrontDesk() {
2929
const showClassicEncounterSearch = data?.showClassicEncounters;
3030
const checkLoginStatus = () => {
3131
axios
32-
.head("/api/v3/user")
32+
.head("/api/v3/user", { timeout: 5000 })
3333
.then((response) => {
34-
setIsLoggedIn(response.status === 200);
34+
if (response.status === 200) {
35+
setIsLoggedIn(true);
36+
}
3537
setLoading(false);
3638
})
3739
.catch((error) => {
38-
console.log("Error", error);
40+
if (error.response?.status === 401) {
41+
setIsLoggedIn(false);
42+
} else {
43+
console.warn("Login status check failed (non-401):", error.message);
44+
}
3945
setLoading(false);
40-
setIsLoggedIn(false);
4146
});
4247
};
4348

@@ -61,6 +66,14 @@ export default function FrontDesk() {
6166
return () => clearInterval(intervalId);
6267
}, []);
6368

69+
useEffect(() => {
70+
const handleOnline = () => {
71+
checkLoginStatus();
72+
};
73+
window.addEventListener("online", handleOnline);
74+
return () => window.removeEventListener("online", handleOnline);
75+
}, []);
76+
6477
useEffect(() => {
6578
if (isLoggedIn) {
6679
getAllNotifications();
@@ -95,18 +108,10 @@ export default function FrontDesk() {
95108
);
96109
}
97110

98-
if (!isLoggedIn) {
99-
return (
100-
<AuthContext.Provider
101-
value={{
102-
isLoggedIn,
103-
}}
104-
>
105-
<GoogleTagManager />
106-
<UnauthenticatedSwitch showclassicsubmit={showclassicsubmit} />
107-
</AuthContext.Provider>
108-
);
109-
}
110-
111-
return <h1>Loading</h1>;
111+
return (
112+
<AuthContext.Provider value={{ isLoggedIn }}>
113+
<GoogleTagManager />
114+
<UnauthenticatedSwitch showclassicsubmit={showclassicsubmit} />
115+
</AuthContext.Provider>
116+
);
112117
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import React from "react";
2+
import { render, screen } from "@testing-library/react";
3+
import ErrorSummaryBar from "../../../pages/BulkImport/BulkImportErrorSummaryBar";
4+
5+
jest.mock("react-intl", () => ({
6+
FormattedMessage: ({ id }) => <span>{id}</span>,
7+
}));
8+
9+
describe("ErrorSummaryBar", () => {
10+
const renderWithStore = (validationErrors, emptyFieldCount = 0) => {
11+
const store = { validationErrors, emptyFieldCount };
12+
render(<ErrorSummaryBar store={store} />);
13+
};
14+
15+
test("shows zeros when there are no errors and no empty fields", () => {
16+
renderWithStore({}, 0);
17+
18+
expect(screen.getByText(/BULK_IMPORT_ERROR/i)).toBeInTheDocument();
19+
expect(screen.getByText(/BULK_IMPORT_MISSING_FIELD/i)).toBeInTheDocument();
20+
expect(screen.getByText(/BULK_IMPORT_EMPTY_FIELD/i)).toBeInTheDocument();
21+
const items = screen.getAllByText(/\d+/);
22+
expect(items).toHaveLength(3);
23+
});
24+
25+
test("correctly counts required vs invalid/missing errors", () => {
26+
const errors = {
27+
row1: {
28+
colA: "This field is required",
29+
colB: "Invalid format detected",
30+
colC: "Value missing in submission",
31+
},
32+
row2: {
33+
colX: "Required value not provided",
34+
colY: "unexpected value",
35+
},
36+
};
37+
renderWithStore(errors, 2);
38+
39+
expect(screen.getByText(/BULK_IMPORT_ERROR/i)).toBeInTheDocument();
40+
expect(screen.getByText(/BULK_IMPORT_MISSING_FIELD/i)).toBeInTheDocument();
41+
expect(screen.getByText(/BULK_IMPORT_EMPTY_FIELD/i)).toBeInTheDocument();
42+
const items = screen.getAllByText(/\d+/);
43+
expect(items).toHaveLength(3);
44+
});
45+
46+
test("renders container with correct id and badge classes", () => {
47+
const errors = {
48+
r1: { a: "required" },
49+
};
50+
renderWithStore(errors, 5);
51+
52+
const container = document.querySelector("#bulk-import-error-summary-bar");
53+
expect(container).toBeInTheDocument();
54+
expect(container).toHaveClass("d-flex", "gap-2", "py-2");
55+
56+
const badges = container.querySelectorAll(".badge");
57+
expect(badges.length).toBe(3);
58+
expect(badges[0]).toHaveClass("bg-danger");
59+
expect(badges[1]).toHaveClass("bg-danger");
60+
expect(badges[2]).toHaveClass("bg-primary");
61+
});
62+
});
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import React from "react";
2+
import { render, screen, waitFor } from "@testing-library/react";
3+
import "@testing-library/jest-dom";
4+
import BulkImport from "../../../pages/BulkImport/BulkImport";
5+
import BulkImportStore from "../../../pages/BulkImport/BulkImportStore";
6+
import useGetBulkImportTask from "../../../models/bulkImport/useGetBulkImportTask";
7+
8+
jest.mock("../../../pages/BulkImport/BulkImportImageUpload", () => ({
9+
BulkImportImageUpload: () => <div data-testid="image-upload" />,
10+
}));
11+
jest.mock("../../../pages/BulkImport/BulkImportUploadProgress", () => ({
12+
BulkImportUploadProgress: () => <div data-testid="upload-progress" />,
13+
}));
14+
jest.mock("../../../pages/BulkImport/BulkImportSpreadsheet", () => ({
15+
BulkImportSpreadsheet: () => <div data-testid="spreadsheet" />,
16+
}));
17+
jest.mock("../../../pages/BulkImport/BulkImportTableReview", () => ({
18+
BulkImportTableReview: () => <div data-testid="table-review" />,
19+
}));
20+
jest.mock("../../../pages/BulkImport/BulkImportSetLocation", () => ({
21+
BulkImportSetLocation: () => <div data-testid="set-location" />,
22+
}));
23+
jest.mock("../../../pages/BulkImport/BulkImportContinueModal", () => ({
24+
BulkImportContinueModal: () => <div data-testid="continue-modal" />,
25+
}));
26+
jest.mock("../../../pages/BulkImport/BulkImportUnfinishedTaskModal", () => ({
27+
BulkImportUnfinishedTaskModal: () => <div data-testid="unfinished-modal" />,
28+
}));
29+
30+
jest.mock("../../../pages/BulkImport/BulkImportInstructionsModal", () => {
31+
const MockInstructionsModal = () => <div data-testid="instructions-modal" />;
32+
MockInstructionsModal.displayName = "BulkImportInstructionsModal";
33+
return MockInstructionsModal;
34+
});
35+
jest.mock("../../../pages/BulkImport/BulkImportDraftSavedIndicator", () => {
36+
const MockDraftIndicator = () => <div data-testid="draft-indicator" />;
37+
MockDraftIndicator.displayName = "DraftSaveIndicator";
38+
return MockDraftIndicator;
39+
});
40+
41+
jest.mock("../../../pages/BulkImport/BulkImportStore");
42+
jest.mock("../../../models/bulkImport/useGetBulkImportTask");
43+
44+
describe("BulkImport Component", () => {
45+
let storeMock;
46+
47+
beforeEach(() => {
48+
localStorage.clear();
49+
jest.clearAllMocks();
50+
51+
storeMock = {
52+
activeStep: 0,
53+
uploadedImages: [],
54+
spreadsheetData: [],
55+
submissionId: null,
56+
saveState: jest.fn(),
57+
};
58+
BulkImportStore.mockImplementation(() => storeMock);
59+
60+
useGetBulkImportTask.mockReturnValue({ task: null });
61+
});
62+
63+
const renderComponent = () => render(<BulkImport />);
64+
65+
it("renders header and fixed child components", () => {
66+
renderComponent();
67+
expect(
68+
screen.getByRole("heading", { name: /BULK_IMPORT/i }),
69+
).toBeInTheDocument();
70+
expect(screen.getByTestId("upload-progress")).toBeInTheDocument();
71+
expect(screen.getByTestId("draft-indicator")).toBeInTheDocument();
72+
expect(screen.getByTestId("instructions-modal")).toBeInTheDocument();
73+
});
74+
75+
it("shows only the image‐upload step when activeStep is 0", () => {
76+
storeMock.activeStep = 0;
77+
renderComponent();
78+
expect(screen.getByTestId("image-upload")).toBeVisible();
79+
expect(screen.queryByTestId("spreadsheet")).toBeNull();
80+
expect(screen.queryByTestId("table-review")).toBeNull();
81+
expect(screen.queryByTestId("set-location")).toBeNull();
82+
});
83+
84+
it("shows the spreadsheet step when activeStep is 1", async () => {
85+
storeMock.activeStep = 1;
86+
renderComponent();
87+
await waitFor(() => {
88+
expect(screen.getByTestId("spreadsheet")).toBeVisible();
89+
});
90+
expect(screen.queryByTestId("image-upload")).not.toBeVisible();
91+
});
92+
93+
it("shows table‐review at step 2 and set‐location at step 3", async () => {
94+
storeMock.activeStep = 2;
95+
renderComponent();
96+
await waitFor(() => {
97+
expect(screen.getByTestId("table-review")).toBeVisible();
98+
});
99+
100+
jest.clearAllMocks();
101+
storeMock.activeStep = 3;
102+
renderComponent();
103+
await waitFor(() => {
104+
expect(screen.getByTestId("set-location")).toBeVisible();
105+
});
106+
});
107+
108+
it("displays ContinueModal when there is a saved submissionId", () => {
109+
localStorage.setItem(
110+
"BulkImportStore",
111+
JSON.stringify({ submissionId: "abc123" }),
112+
);
113+
renderComponent();
114+
expect(screen.getByTestId("continue-modal")).toBeInTheDocument();
115+
});
116+
117+
it("shows UnfinishedTaskModal for an in‐progress task", () => {
118+
localStorage.setItem("lastBulkImportTask", "task-xyz");
119+
useGetBulkImportTask.mockReturnValue({
120+
task: {
121+
status: "processing",
122+
sourceName: "file1",
123+
dateCreated: "2025-07-02",
124+
},
125+
});
126+
renderComponent();
127+
expect(screen.getByTestId("unfinished-modal")).toBeInTheDocument();
128+
});
129+
130+
it("does not show UnfinishedTaskModal when task is complete", () => {
131+
localStorage.setItem("lastBulkImportTask", "task-xyz");
132+
useGetBulkImportTask.mockReturnValue({
133+
task: {
134+
status: "complete",
135+
sourceName: "file1",
136+
dateCreated: "2025-07-02",
137+
},
138+
});
139+
renderComponent();
140+
expect(screen.queryByTestId("unfinished-modal")).toBeNull();
141+
});
142+
});

0 commit comments

Comments
 (0)