Repository: tuva-health/tuva_empi
Pull request #5: Adds Create New Person button
Author: login: karankrishnani
Created: 2025-03-25T19:08:01Z | Merged: 2025-03-28T18:02:33Z

## Describe your changes
Adds the ability to create a new person when match mode is turned on

## Testing
Added unit test cases.
![image](https://github.com/user-attachments/assets/18031bc1-8046-4d17-a100-0da626f502c3)




Diff:
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 5991966..572817e 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -35,6 +35,7 @@
         "@babel/preset-typescript": "^7.26.0",
         "@testing-library/jest-dom": "^6.6.3",
         "@testing-library/react": "^16.2.0",
+        "@testing-library/user-event": "^14.6.1",
         "@types/jest": "^29.5.14",
         "@types/node": "^20.17.10",
         "@types/react": "^18.3.16",
@@ -4438,6 +4439,20 @@
         }
       }
     },
+    "node_modules/@testing-library/user-event": {
+      "version": "14.6.1",
+      "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+      "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12",
+        "npm": ">=6"
+      },
+      "peerDependencies": {
+        "@testing-library/dom": ">=7.21.4"
+      }
+    },
     "node_modules/@tootallnate/once": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 2f6faf3..510d539 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -42,6 +42,7 @@
     "@babel/preset-typescript": "^7.26.0",
     "@testing-library/jest-dom": "^6.6.3",
     "@testing-library/react": "^16.2.0",
+    "@testing-library/user-event": "^14.6.1",
     "@types/jest": "^29.5.14",
     "@types/node": "^20.17.10",
     "@types/react": "^18.3.16",
diff --git a/frontend/src/app/person_match/record_manager.test.tsx b/frontend/src/app/person_match/record_manager.test.tsx
index 371768b..1087aff 100644
--- a/frontend/src/app/person_match/record_manager.test.tsx
+++ b/frontend/src/app/person_match/record_manager.test.tsx
@@ -7,7 +7,8 @@ import {
 import { PersonRecordWithMetadata } from "@/lib/stores/person_match_slice";
 import { Table, TableBody } from "@/components/ui/table";
 import { type AppStore } from "@/lib/stores/types";
-
+import userEvent from "@testing-library/user-event";
+import { createMockStore } from "@/lib/test_utils";
 // Add mock imports
 jest.mock("next/navigation", () => ({
   useRouter: (): { push: jest.Mock } => ({
@@ -202,16 +203,22 @@ describe("PersonRecordRowDetail", () => {
 });
 
 describe("RecordManager", () => {
+  const mockStore = createMockStore();
+
+  beforeEach(() => {
+    mockUseAppStore.mockReset();
+    mockUseAppStore.mockImplementation(
+      <T,>(selector: (state: AppStore) => T): T => selector(mockStore),
+    );
+  });
+
   const setupMockState = (matchMode: boolean): void => {
     mockUseAppStore.mockImplementation(
       <T,>(selector: (state: AppStore) => T): T => {
-        const mockState: AppStore = {
+        const mockState = createMockStore({
           personMatch: {
-            dataSources: [],
+            ...mockStore.personMatch,
             matchMode,
-            searchTerms: {},
-            potentialMatchSummaries: {},
-            potentialMatches: {},
             currentPotentialMatches: matchMode
               ? {
                   "match-1": {
@@ -230,8 +237,6 @@ describe("RecordManager", () => {
                 }
               : {},
             selectedPotentialMatchId: matchMode ? "match-1" : null,
-            personSummaries: {},
-            persons: {},
             currentPersons: !matchMode
               ? {
                   "test-id": {
@@ -243,21 +248,8 @@ describe("RecordManager", () => {
                 }
               : {},
             selectedPersonId: !matchMode ? "test-id" : null,
-            fetchDataSources: jest.fn().mockResolvedValue(undefined),
-            setMatchMode: jest.fn(),
-            updateSearchTerms: jest.fn(),
-            clearSearchTerms: jest.fn(),
-            fetchSummaries: jest.fn().mockResolvedValue(undefined),
-            selectSummary: jest.fn(),
-            fetchPotentialMatch: jest.fn().mockResolvedValue(undefined),
-            resetCurrentPotentialMatch: jest.fn(),
-            fetchPerson: jest.fn().mockResolvedValue(undefined),
-            resetCurrentPerson: jest.fn(),
-            movePersonRecord: jest.fn(),
-            matchPersonRecords: jest.fn().mockResolvedValue(undefined),
-            setPersonRecordExpanded: jest.fn(),
           },
-        };
+        });
         return selector(mockState);
       },
     );
@@ -307,4 +299,153 @@ describe("RecordManager", () => {
       expect(header.textContent).toBe(expectedHeaders[index]);
     });
   });
+
+  describe("Create New Person functionality", () => {
+    it("should show Create New Person button only when matchMode is true", () => {
+      // Set up store with matchMode true and a selected potential match
+      const storeWithMatchMode = createMockStore({
+        personMatch: {
+          ...mockStore.personMatch,
+          matchMode: true,
+          selectedPotentialMatchId: "test-match-1",
+          currentPotentialMatches: {
+            "test-match-1": {
+              id: "test-match-1",
+              version: 1,
+              persons: {},
+              results: [],
+            },
+          },
+        },
+      });
+
+      mockUseAppStore.mockImplementation(
+        <T,>(selector: (state: AppStore) => T): T =>
+          selector(storeWithMatchMode),
+      );
+
+      render(<RecordManager />);
+      expect(screen.getByText("+ Create New Person")).toBeInTheDocument();
+    });
+
+    it("should not show Create New Person button when matchMode is false", () => {
+      // Set up store with matchMode false
+      const storeWithoutMatchMode = createMockStore({
+        personMatch: {
+          ...mockStore.personMatch,
+          matchMode: false,
+          selectedPersonId: "test-person-1",
+          currentPersons: {
+            "test-person-1": {
+              id: "test-person-1",
+              version: 1,
+              created: new Date(),
+              records: [],
+            },
+          },
+        },
+      });
+
+      mockUseAppStore.mockImplementation(
+        <T,>(selector: (state: AppStore) => T): T =>
+          selector(storeWithoutMatchMode),
+      );
+
+      render(<RecordManager />);
+      expect(screen.queryByText("+ Create New Person")).not.toBeInTheDocument();
+    });
+
+    it("should create a new person row with 'New Person 1' when button is clicked", async () => {
+      // Set up store with matchMode true and a selected potential match
+      const storeWithMatchMode = createMockStore({
+        personMatch: {
+          ...mockStore.personMatch,
+          matchMode: true,
+          selectedPotentialMatchId: "test-match-1",
+          currentPotentialMatches: {
+            "test-match-1": {
+              id: "test-match-1",
+              version: 1,
+              persons: {},
+              results: [],
+            },
+          },
+        },
+      });
+
+      mockUseAppStore.mockImplementation(
+        <T,>(selector: (state: AppStore) => T): T =>
+          selector(storeWithMatchMode),
+      );
+
+      render(<RecordManager />);
+
+      const createButton = screen.getByText("+ Create New Person");
+      await userEvent.click(createButton);
+
+      expect(mockStore.personMatch.createNewPerson).toHaveBeenCalledWith(
+        "test-match-1",
+      );
+    });
+
+    it("should increment new person number for each additional person created", async () => {
+      // Set up store with matchMode true and a selected potential match with one new person
+      const storeWithNewPerson = createMockStore({
+        personMatch: {
+          ...mockStore.personMatch,
+          matchMode: true,
+          selectedPotentialMatchId: "test-match-1",
+          currentPotentialMatches: {
+            "test-match-1": {
+              id: "test-match-1",
+              version: 1,
+              persons: {
+                "new-person-1": {
+                  id: "new-person-1",
+                  created: new Date(),
+                  records: [],
+                },
+              },
+              results: [],
+            },
+          },
+        },
+      });
+
+      mockUseAppStore.mockImplementation(
+        <T,>(selector: (state: AppStore) => T): T =>
+          selector(storeWithNewPerson),
+      );
+
+      const { unmount } = render(<RecordManager />);
+
+      const createButton = screen.getByText("+ Create New Person");
+      await userEvent.click(createButton);
+
+      expect(mockStore.personMatch.createNewPerson).toHaveBeenCalledWith(
+        "test-match-1",
+      );
+
+      // Update the mock store to reflect the state change by appending the new person
+      storeWithNewPerson.personMatch.currentPotentialMatches[
+        "test-match-1"
+      ].persons = {
+        ...storeWithNewPerson.personMatch.currentPotentialMatches[
+          "test-match-1"
+        ].persons,
+        "new-person-2": {
+          id: "new-person-2",
+          created: new Date(),
+          records: [],
+        },
+      };
+
+      // Clean up previous render and re-render with new state
+      unmount();
+      render(<RecordManager />);
+
+      expect(screen.getByText("New Person 1")).toBeInTheDocument();
+      expect(screen.getByText("New Person 2")).toBeInTheDocument();
+    });
+  });
 });
diff --git a/frontend/src/app/person_match/record_manager.tsx b/frontend/src/app/person_match/record_manager.tsx
index 9004c6a..7df0793 100644
--- a/frontend/src/app/person_match/record_manager.tsx
+++ b/frontend/src/app/person_match/record_manager.tsx
@@ -218,9 +218,9 @@ const PersonRow: React.FC<PersonRowProps> = ({
     "bg-chart-4/25",
     "bg-chart-5/25",
   ];
-  const bgClassName = bgClassNames[ndx % 3];
-  const bgClassNameMuted = bgClassNamesMuted[ndx % 3];
-  const bgClassNameLessMuted = bgClassNamesLessMuted[ndx % 3];
+  const bgClassName = bgClassNames[ndx % 5];
+  const bgClassNameMuted = bgClassNamesMuted[ndx % 5];
+  const bgClassNameLessMuted = bgClassNamesLessMuted[ndx % 5];
 
   const ref = React.useRef(null);
   const { dropProps, isDropTarget } = useDrop({
@@ -241,6 +241,12 @@ const PersonRow: React.FC<PersonRowProps> = ({
     },
   });
 
+  const isNewPerson = person.id.startsWith("new-person-");
+  const personIndex = isNewPerson
+    ? parseInt(person.id.replace("new-person-", ""))
+    : undefined;
+  const displayText = isNewPerson ? `New Person ${personIndex}` : person.id;
+
   return (
     <React.Fragment>
       <TableRow
@@ -249,7 +255,7 @@ const PersonRow: React.FC<PersonRowProps> = ({
         {...dropProps}
       >
         <TableCell className={`${bgClassName}`}></TableCell>
-        <TableCell className={`${bgClassNameMuted}`}>{person.id}</TableCell>
+        <TableCell className={`${bgClassNameMuted}`}>{displayText}</TableCell>
         <TableCell className={`${bgClassNameMuted}`}></TableCell>
         <TableCell className={`${bgClassNameMuted}`}></TableCell>
         <TableCell className={`${bgClassNameMuted}`}></TableCell>
@@ -304,6 +310,9 @@ export const RecordManager: React.FC = () => {
   const matchPersonRecords = useAppStore(
     (state) => state.personMatch.matchPersonRecords,
   );
+  const createNewPerson = useAppStore(
+    (state) => state.personMatch.createNewPerson,
+  );
 
   const potentialMatch =
     matchMode && selectedPotentialMatchId
@@ -406,6 +415,16 @@ export const RecordManager: React.FC = () => {
               </TableBody>
             </Table>
           </div>
+          {matchMode && potentialMatch && (
+            <div className="flex flex-row items-start justify-start pt-6">
+              <Button
+                variant="outline"
+                onClick={() => createNewPerson(potentialMatch.id)}
+              >
+                + Create New Person
+              </Button>
+            </div>
+          )}
         </div>
       ) : loading ? (
         <div className="flex flex-row items-center justify-center max-h-[444px] h-full w-full">
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 79663aa..0f98970 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -22,7 +22,7 @@ export interface PersonSummary {
 export interface Person {
   id: string;
   created: Date;
-  version: number;
+  version?: number;
   records: PersonRecord[];
 }
 
diff --git a/frontend/src/lib/stores/person_match_slice.test.ts b/frontend/src/lib/stores/person_match_slice.test.ts
index 1294ca2..c26d94c 100644
--- a/frontend/src/lib/stores/person_match_slice.test.ts
+++ b/frontend/src/lib/stores/person_match_slice.test.ts
@@ -3,9 +3,11 @@ import { immer } from "zustand/middleware/immer";
 import {
   createPersonMatchSlice,
   defaultInitState,
+  PersonWithMetadata,
+  PersonRecordWithMetadata,
   calculateHighestMatchProbabilities,
 } from "./person_match_slice";
-import { PersonSummary, PotentialMatchSummary } from "../api";
+import { PersonSummary, PotentialMatchSummary, PredictionResult } from "../api";
 import * as api from "../api";
 import { AppStore } from "./types";
 
@@ -167,6 +169,594 @@ describe("PersonMatchSlice", () => {
       "source2",
     ]);
   });
+
+  describe("matchPersonRecords", () => {
+    it("should handle existing and new persons correctly", async () => {
+      const useTestStore = create<AppStore>()(
+        immer((set, get, store) => ({
+          ...defaultInitState,
+          ...createPersonMatchSlice(defaultInitState)(set, get, store),
+        })),
+      );
+
+      // Setup initial state with a potential match containing both existing and new persons
+      const potentialMatchId = "match-123";
+      const existingPerson: PersonWithMetadata = {
+        id: "existing-123",
+        version: 1,
+        records: [
+          { id: "record-1", expanded: false } as PersonRecordWithMetadata,
+          { id: "record-2", expanded: false } as PersonRecordWithMetadata,
+        ],
+        created: new Date(),
+      };
+      const newPersonWithRecords: PersonWithMetadata = {
+        id: "new-person-1",
+        records: [
+          { id: "record-3", expanded: false } as PersonRecordWithMetadata,
+        ],
+        created: new Date(),
+      };
+      const newPersonNoRecords: PersonWithMetadata = {
+        id: "new-person-2",
+        records: [],
+        created: new Date(),
+      };
+
+      const mockResults: PredictionResult[] = [
+        {
+          id: "pred-1",
+          person_record_l_id: "record-1",
+          person_record_r_id: "record-2",
+          match_probability: 0.9,
+        },
+      ];
+
+      useTestStore.setState((state: AppStore) => {
+        state.personMatch.currentPotentialMatches[potentialMatchId] = {
+          id: potentialMatchId,
+          version: 1,
+          persons: {
+            [existingPerson.id]: existingPerson,
+            [newPersonWithRecords.id]: newPersonWithRecords,
+            [newPersonNoRecords.id]: newPersonNoRecords,
+          },
+          results: mockResults,
+        };
+      });
+
+      // Mock API call
+      (api.matchPersonRecords as jest.Mock).mockResolvedValue(undefined);
+
+      // Call matchPersonRecords
+      await useTestStore
+        .getState()
+        .personMatch.matchPersonRecords(potentialMatchId);
+
+      // Verify API was called with correct parameters
+      expect(api.matchPersonRecords).toHaveBeenCalledWith(potentialMatchId, 1, [
+        {
+          id: "existing-123",
+          version: 1,
+          new_person_record_ids: ["record-1", "record-2"],
+        },
+        {
+          new_person_record_ids: ["record-3"],
+        },
+      ]);
+
+      // Verify state cleanup
+      const state = useTestStore.getState();
+      expect(
+        state.personMatch.potentialMatchSummaries[potentialMatchId],
+      ).toBeUndefined();
+      expect(
+        state.personMatch.potentialMatches[potentialMatchId],
+      ).toBeUndefined();
+      expect(
+        state.personMatch.currentPotentialMatches[potentialMatchId],
+      ).toBeUndefined();
+      expect(state.personMatch.selectedPotentialMatchId).toBeNull();
+    });
+
+    it("should handle error when potential match IDs don't match", async () => {
+      const useTestStore = create<AppStore>()(
+        immer((set, get, store) => ({
+          ...defaultInitState,
+          ...createPersonMatchSlice(defaultInitState)(set, get, store),
+        })),
+      );
+
+      // Setup initial state with mismatched IDs
+      const potentialMatchId = "match-123";
+      useTestStore.setState((state: AppStore) => {
+        state.personMatch.currentPotentialMatches[potentialMatchId] = {
+          id: "different-id",
+          version: 1,
+          persons: {},
+          results: [],
+        };
+      });
+
+      // Mock console.error
+      const consoleSpy = jest.spyOn(console, "error");
+
+      // Call matchPersonRecords
+      await useTestStore
+        .getState()
+        .personMatch.matchPersonRecords(potentialMatchId);
+
+      // Verify error was logged
+      expect(consoleSpy).toHaveBeenCalledWith("Failed to match PersonRecords");
+
+      // Verify API was not called
+      expect(api.matchPersonRecords).not.toHaveBeenCalled();
+
+      consoleSpy.mockRestore();
+    });
+
+    it("should allow dragging records into new person rows", async () => {
+      const useTestStore = create<AppStore>()(
+        immer((set, get, store) => ({
+          ...defaultInitState,
+          ...createPersonMatchSlice(defaultInitState)(set, get, store),
+        })),
+      );
+
+      const potentialMatchId = "match-123";
+      const existingPerson: PersonWithMetadata = {
+        id: "existing-123",
+        version: 1,
+        records: [
+          { id: "record-1", expanded: false } as PersonRecordWithMetadata,
+          { id: "record-2", expanded: false } as PersonRecordWithMetadata,
+        ],
+        created: new Date(),
+      };
+
+      useTestStore.setState((state: AppStore) => {
+        state.personMatch.currentPotentialMatches[potentialMatchId] = {
+          id: potentialMatchId,
+          version: 1,
+          persons: {
+            [existingPerson.id]: existingPerson,
+          },
+          results: [],
+        };
+      });
+
+      // Create a new person
+      useTestStore.getState().personMatch.createNewPerson(potentialMatchId);
+
+      // Move a record to the new person
+      useTestStore.getState().personMatch.movePersonRecord(
+        potentialMatchId,
+        {
+          ...existingPerson.records[0],
+          person_id: existingPerson.id,
+        } as PersonRecordWithMetadata,
+        "new-person-1",
+      );
+
+      const state = useTestStore.getState();
+      const persons =
+        state.personMatch.currentPotentialMatches[potentialMatchId].persons;
+
+      // Check record was moved correctly
+      expect(persons["existing-123"].records.length).toBe(1);
+      expect(persons["existing-123"].records[0].id).toBe("record-2");
+      expect(persons["new-person-1"].records.length).toBe(1);
+      expect(persons["new-person-1"].records[0].id).toBe("record-1");
+      expect(persons["new-person-1"].records[0].person_id).toBe("new-person-1");
+    });
+
+    it("should handle canceling with new persons correctly", async () => {
+      const useTestStore = create<AppStore>()(
+        immer((set, get, store) => ({
+          ...defaultInitState,
+          ...createPersonMatchSlice(defaultInitState)(set, get, store),
+        })),
+      );
+
+      const potentialMatchId = "match-123";
+      const existingPerson: PersonWithMetadata = {
+        id: "existing-123",
+        version: 1,
+        records: [
+          { id: "record-1", expanded: false } as PersonRecordWithMetadata,
+          { id: "record-2", expanded: false } as PersonRecordWithMetadata,
+        ],
+        created: new Date(),
+      };
+
+      // Set up initial state in both potentialMatches and currentPotentialMatches
+      useTestStore.setState((state: AppStore) => {
+        // Original state
+        state.personMatch.potentialMatches[potentialMatchId] = {
+          id: potentialMatchId,
+          version: 1,
+          persons: [existingPerson],
+          results: [],
+        };
+
+        // Current state with modifications
+        state.personMatch.currentPotentialMatches[potentialMatchId] = {
+          id: potentialMatchId,
+          version: 1,
+          persons: {
+            [existingPerson.id]: {
+              ...existingPerson,
+              records: [existingPerson.records[0]], // Moved one record out
+            },
+            "new-person-1": {
+              id: "new-person-1",
+              created: new Date(),
+              records: [
+                {
+                  ...existingPerson.records[1],
+                  person_id: "new-person-1",
+                } as PersonRecordWithMetadata,
+              ],
+            },
+          },
+          results: [],
+        };
+      });
+
+      // Reset the current potential match
+      useTestStore
+        .getState()
+        .personMatch.resetCurrentPotentialMatch(potentialMatchId);
+
+      const state = useTestStore.getState();
+      const currentMatch =
+        state.personMatch.currentPotentialMatches[potentialMatchId];
+
+      // Verify state was reset correctly
+      expect(Object.keys(currentMatch.persons).length).toBe(1);
+      expect(currentMatch.persons["existing-123"]).toBeDefined();
+      expect(currentMatch.persons["existing-123"].records.length).toBe(2);
+      expect(currentMatch.persons["new-person-1"]).toBeUndefined();
+    });
+  });
+
+  describe("createNewPerson", () => {
+    it("should generate correct pseudo IDs for new persons", async () => {
+      const useTestStore = create<AppStore>()(
+        immer((set, get, store) => ({
+          ...defaultInitState,
+          ...createPersonMatchSlice(defaultInitState)(set, get, store),
+        })),
+      );
+
+      const potentialMatchId = "match-123";
+      useTestStore.setState((state: AppStore) => {
+        state.personMatch.currentPotentialMatches[potentialMatchId] = {
+          id: potentialMatchId,
+          version: 1,
+          persons: {},
+          results: [],
+        };
+      });
+
+      // Create three new persons
+      useTestStore.getState().personMatch.createNewPerson(potentialMatchId);
+      useTestStore.getState().personMatch.createNewPerson(potentialMatchId);
+      useTestStore.getState().personMatch.createNewPerson(potentialMatchId);
+
+      const state = useTestStore.getState();
+      const persons =
+        state.personMatch.currentPotentialMatches[potentialMatchId].persons;
+
+      expect(persons["new-person-1"]).toBeDefined();
+      expect(persons["new-person-2"]).toBeDefined();
+      expect(persons["new-person-3"]).toBeDefined();
+      expect(Object.keys(persons).length).toBe(3);
+    });
+
+    it("should handle non-existent potential match", () => {
+      const useTestStore = create<AppStore>()(
+        immer((set, get, store) => ({
+          ...defaultInitState,
+          ...createPersonMatchSlice(defaultInitState)(set, get, store),
+        })),
+      );
+
+      const consoleSpy = jest.spyOn(console, "error");
+      useTestStore.getState().personMatch.createNewPerson("non-existent-match");
+
+      expect(consoleSpy).toHaveBeenCalledWith(
+        "Current PotentialMatch does not exist",
+      );
+      consoleSpy.mockRestore();
+    });
+  });
+
+  describe("movePersonRecord", () => {
+    it("should allow moving records between existing persons", () => {
+      const useTestStore = create<AppStore>()(
+        immer((set, get, store) => ({
+          ...defaultInitState,
+          ...createPersonMatchSlice(defaultInitState)(set, get, store),
+        })),
+      );
+
+      const potentialMatchId = "match-123";
+      const person1: PersonWithMetadata = {
+        id: "person-1",
+        version: 1,
+        records: [
+          { id: "record-1", expanded: false } as PersonRecordWithMetadata,
+        ],
+        created: new Date(),
+      };
+      const person2: PersonWithMetadata = {
+        id: "person-2",
+        version: 1,
+        records: [],
+        created: new Date(),
+      };
+
+      useTestStore.setState((state: AppStore) => {
+        state.personMatch.currentPotentialMatches[potentialMatchId] = {
+          id: potentialMatchId,
+          version: 1,
+          persons: {
+            [person1.id]: person1,
+            [person2.id]: person2,
+          },
+          results: [],
+        };
+      });
+
+      // Move record from person1 to person2
+      useTestStore.getState().personMatch.movePersonRecord(
+        potentialMatchId,
+        {
+          ...person1.records[0],
+          person_id: person1.id,
+        } as PersonRecordWithMetadata,
+        person2.id,
+      );
+
+      const state = useTestStore.getState();
+      const persons =
+        state.personMatch.currentPotentialMatches[potentialMatchId].persons;
+
+      expect(persons[person1.id].records.length).toBe(0);
+      expect(persons[person2.id].records.length).toBe(1);
+      expect(persons[person2.id].records[0].id).toBe("record-1");
+      expect(persons[person2.id].records[0].person_id).toBe(person2.id);
+    });
+
+    it("should handle non-existent potential match", () => {
+      const useTestStore = create<AppStore>()(
+        immer((set, get, store) => ({
+          ...defaultInitState,
+          ...createPersonMatchSlice(defaultInitState)(set, get, store),
+        })),
+      );
+
+      const consoleSpy = jest.spyOn(console, "error");
+      useTestStore
+        .getState()
+        .personMatch.movePersonRecord(
+          "non-existent-match",
+          { id: "record-1", person_id: "person-1" } as PersonRecordWithMetadata,
+          "person-2",
+        );
+
+      expect(consoleSpy).toHaveBeenCalledWith(
+        "Current PotentialMatch does not exist",
+      );
+      consoleSpy.mockRestore();
+    });
+
+    it("should do nothing when moving to same person", () => {
+      const useTestStore = create<AppStore>()(
+        immer((set, get, store) => ({
+          ...defaultInitState,
+          ...createPersonMatchSlice(defaultInitState)(set, get, store),
+        })),
+      );
+
+      const potentialMatchId = "match-123";
+      const person: PersonWithMetadata = {
+        id: "person-1",
+        version: 1,
+        records: [
+          { id: "record-1", expanded: false } as PersonRecordWithMetadata,
+        ],
+        created: new Date(),
+      };
+
+      useTestStore.setState((state: AppStore) => {
+        state.personMatch.currentPotentialMatches[potentialMatchId] = {
+          id: potentialMatchId,
+          version: 1,
+          persons: {
+            [person.id]: person,
+          },
+          results: [],
+        };
+      });
+
+      // Try to move record to same person
+      useTestStore.getState().personMatch.movePersonRecord(
+        potentialMatchId,
+        {
+          ...person.records[0],
+          person_id: person.id,
+        } as PersonRecordWithMetadata,
+        person.id,
+      );
+
+      const state = useTestStore.getState();
+      const persons =
+        state.personMatch.currentPotentialMatches[potentialMatchId].persons;
+
+      expect(persons[person.id].records.length).toBe(1);
+      expect(persons[person.id].records[0].id).toBe("record-1");
+    });
+  });
+
+  describe("resetCurrentPotentialMatch", () => {
+    it("should handle canceling with new persons correctly", async () => {
+      const useTestStore = create<AppStore>()(
+        immer((set, get, store) => ({
+          ...defaultInitState,
+          ...createPersonMatchSlice(defaultInitState)(set, get, store),
+        })),
+      );
+
+      const potentialMatchId = "match-123";
+      const existingPerson: PersonWithMetadata = {
+        id: "existing-123",
+        version: 1,
+        records: [
+          { id: "record-1", expanded: false } as PersonRecordWithMetadata,
+          { id: "record-2", expanded: false } as PersonRecordWithMetadata,
+        ],
+        created: new Date(),
+      };
+
+      // Set up initial state in both potentialMatches and currentPotentialMatches
+      useTestStore.setState((state: AppStore) => {
+        // Original state
+        state.personMatch.potentialMatches[potentialMatchId] = {
+          id: potentialMatchId,
+          version: 1,
+          persons: [existingPerson],
+          results: [],
+        };
+
+        // Current state with modifications
+        state.personMatch.currentPotentialMatches[potentialMatchId] = {
+          id: potentialMatchId,
+          version: 1,
+          persons: {
+            [existingPerson.id]: {
+              ...existingPerson,
+              records: [existingPerson.records[0]], // Moved one record out
+            },
+            "new-person-1": {
+              id: "new-person-1",
+              created: new Date(),
+              records: [
+                {
+                  ...existingPerson.records[1],
+                  person_id: "new-person-1",
+                } as PersonRecordWithMetadata,
+              ],
+            },
+          },
+          results: [],
+        };
+      });
+
+      // Reset the current potential match
+      useTestStore
+        .getState()
+        .personMatch.resetCurrentPotentialMatch(potentialMatchId);
+
+      const state = useTestStore.getState();
+      const currentMatch =
+        state.personMatch.currentPotentialMatches[potentialMatchId];
+
+      expect(Object.keys(currentMatch.persons).length).toBe(1);
+      expect(currentMatch.persons["existing-123"]).toBeDefined();
+      expect(currentMatch.persons["existing-123"].records.length).toBe(2);
+      expect(currentMatch.persons["new-person-1"]).toBeUndefined();
+    });
+
+    it("should handle non-existent potential match", () => {
+      const useTestStore = create<AppStore>()(
+        immer((set, get, store) => ({
+          ...defaultInitState,
+          ...createPersonMatchSlice(defaultInitState)(set, get, store),
+        })),
+      );
+
+      const consoleSpy = jest.spyOn(console, "error");
+      useTestStore
+        .getState()
+        .personMatch.resetCurrentPotentialMatch("non-existent-match");
+
+      expect(consoleSpy).toHaveBeenCalledWith("PotentialMatch does not exist");
+      consoleSpy.mockRestore();
+    });
+
+    it("should preserve match probabilities when resetting", () => {
+      const useTestStore = create<AppStore>()(
+        immer((set, get, store) => ({
+          ...defaultInitState,
+          ...createPersonMatchSlice(defaultInitState)(set, get, store),
+        })),
+      );
+
+      const potentialMatchId = "match-123";
+      const existingPerson: PersonWithMetadata = {
+        id: "existing-123",
+        version: 1,
+        records: [
+          { id: "record-1", expanded: false } as PersonRecordWithMetadata,
+          { id: "record-2", expanded: false } as PersonRecordWithMetadata,
+        ],
+        created: new Date(),
+      };
+
+      const mockResults = [
+        {
+          id: "pred-1",
+          person_record_l_id: "record-1",
+          person_record_r_id: "record-2",
+          match_probability: 0.9,
+        },
+      ];
+
+      useTestStore.setState((state: AppStore) => {
+        state.personMatch.potentialMatches[potentialMatchId] = {
+          id: potentialMatchId,
+          version: 1,
+          persons: [existingPerson],
+          results: mockResults,
+        };
+
+        state.personMatch.currentPotentialMatches[potentialMatchId] = {
+          id: potentialMatchId,
+          version: 1,
+          persons: {
+            [existingPerson.id]: {
+              ...existingPerson,
+              records: [
+                {
+                  ...existingPerson.records[0],
+                  highest_match_probability: 0.5,
+                },
+                {
+                  ...existingPerson.records[1],
+                  highest_match_probability: 0.5,
+                },
+              ],
+            },
+          },
+          results: mockResults,
+        };
+      });
+
+      useTestStore
+        .getState()
+        .personMatch.resetCurrentPotentialMatch(potentialMatchId);
+
+      const state = useTestStore.getState();
+      const currentMatch =
+        state.personMatch.currentPotentialMatches[potentialMatchId];
+      const records = currentMatch.persons["existing-123"].records;
+
+      expect(records[0].highest_match_probability).toBe(0.9);
+      expect(records[1].highest_match_probability).toBe(0.9);
+    });
+  });
 });
 
 describe("calculateHighestMatchProbabilities", () => {
diff --git a/frontend/src/lib/stores/person_match_slice.ts b/frontend/src/lib/stores/person_match_slice.ts
index 8dc12f7..f85a0a0 100644
--- a/frontend/src/lib/stores/person_match_slice.ts
+++ b/frontend/src/lib/stores/person_match_slice.ts
@@ -106,6 +106,11 @@ export interface PersonMatchActions {
       expanded: boolean,
       potentialMatchId?: string,
     ) => void;
+
+    /**
+     * Creates a new person in the current potential match
+     */
+    createNewPerson: (potentialMatchId: string) => void;
   };
 }
 
@@ -304,8 +309,10 @@ export const createPersonMatchSlice =
         }
 
         for (const person of Object.values(potentialMatch.persons)) {
+          const isNewPerson = person.id.startsWith("new-person-");
+
           // If it's a new Person without any records, don't upload it
-          if (person.id === "" && person.records.length === 0) {
+          if (isNewPerson && person.records.length === 0) {
             continue;
           }
 
@@ -313,12 +320,14 @@ export const createPersonMatchSlice =
             new_person_record_ids: person.records.map((record) => record.id),
           };
 
-          if (person.id) {
-            update.id = person.id;
-          }
-
-          if (person.version) {
-            update.version = person.version;
+          // Only include id and version for existing persons (not new ones)
+          if (!isNewPerson) {
+            if (person.id) {
+              update.id = person.id;
+            }
+            if (person.version) {
+              update.version = person.version;
+            }
           }
 
           person_updates.push(update);
@@ -574,5 +583,34 @@ export const createPersonMatchSlice =
           };
         });
       },
+
+      /**
+       * Creates a new person in the current potential match
+       */
+      createNewPerson: (potentialMatchId: string): void => {
+        set((state) => {
+          const potentialMatch =
+            state.personMatch.currentPotentialMatches[potentialMatchId];
+          if (!potentialMatch) {
+            console.error("Current PotentialMatch does not exist");
+            return;
+          }
+
+          // Find the next available index
+          const newPersonIds = Object.keys(potentialMatch.persons)
+            .filter((id) => id.startsWith("new-person-"))
+            .map((id) => parseInt(id.replace("new-person-", "")))
+            .sort((a, b) => b - a);
+
+          const nextIndex = newPersonIds.length > 0 ? newPersonIds[0] + 1 : 1;
+          const newPersonId = `new-person-${nextIndex}`;
+
+          potentialMatch.persons[newPersonId] = {
+            id: newPersonId,
+            created: new Date(),
+            records: [],
+          };
+        });
+      },
     },
   });
diff --git a/frontend/src/lib/test_utils.ts b/frontend/src/lib/test_utils.ts
new file mode 100644
index 0000000..dcfce55
--- /dev/null
+++ b/frontend/src/lib/test_utils.ts
@@ -0,0 +1,40 @@
+import { AppStore } from "./stores/types";
+
+export const createMockStore = (
+  overrides: Partial<AppStore> = {},
+): AppStore => {
+  const defaultStore: AppStore = {
+    personMatch: {
+      dataSources: [],
+      matchMode: false,
+      searchTerms: {},
+      potentialMatchSummaries: {},
+      potentialMatches: {},
+      currentPotentialMatches: {},
+      selectedPotentialMatchId: null,
+      personSummaries: {},
+      persons: {},
+      currentPersons: {},
+      selectedPersonId: null,
+      createNewPerson: jest.fn(),
+      fetchDataSources: jest.fn().mockResolvedValue(undefined),
+      setMatchMode: jest.fn(),
+      updateSearchTerms: jest.fn(),
+      clearSearchTerms: jest.fn(),
+      fetchSummaries: jest.fn().mockResolvedValue(undefined),
+      selectSummary: jest.fn(),
+      fetchPotentialMatch: jest.fn().mockResolvedValue(undefined),
+      resetCurrentPotentialMatch: jest.fn(),
+      fetchPerson: jest.fn().mockResolvedValue(undefined),
+      resetCurrentPerson: jest.fn(),
+      movePersonRecord: jest.fn(),
+      matchPersonRecords: jest.fn().mockResolvedValue(undefined),
+      setPersonRecordExpanded: jest.fn(),
+    },
+  };
+
+  return {
+    ...defaultStore,
+    ...overrides,
+  };
+};


Reviews:
user: login: lucas-tuva
body: Looks good. I appreciate the `it` titles for the tests. Makes it very easy to read. I think for Python, I'll be more careful to add very descriptive docstrings for each test function.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T17:29:52Z
user: login: karankrishnani
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T18:51:42Z
user: login: karankrishnani
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T18:52:09Z
user: login: lucas-tuva
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T21:04:36Z

Review comments:
diff_hunk: @@ -202,6 +202,43 @@ describe("PersonRecordRowDetail", () => {
 });
 
 describe("RecordManager", () => {
+  const mockStore = {
+    personMatch: {
+      dataSources: [],
+      matchMode: false,
+      searchTerms: {},
+      potentialMatchSummaries: {},
+      potentialMatches: {},
+      currentPotentialMatches: {},
+      selectedPotentialMatchId: null,
+      personSummaries: {},
+      persons: {},
+      currentPersons: {},
+      selectedPersonId: null,
+      createNewPerson: jest.fn(),
+      fetchDataSources: jest.fn().mockResolvedValue(undefined),
+      setMatchMode: jest.fn(),
+      updateSearchTerms: jest.fn(),
+      clearSearchTerms: jest.fn(),
+      fetchSummaries: jest.fn().mockResolvedValue(undefined),
+      selectSummary: jest.fn(),
+      fetchPotentialMatch: jest.fn().mockResolvedValue(undefined),
+      resetCurrentPotentialMatch: jest.fn(),
+      fetchPerson: jest.fn().mockResolvedValue(undefined),
+      resetCurrentPerson: jest.fn(),
+      movePersonRecord: jest.fn(),
+      matchPersonRecords: jest.fn().mockResolvedValue(undefined),
+      setPersonRecordExpanded: jest.fn(),
+    },
path: frontend/src/app/person_match/record_manager.test.tsx
user: login: lucas-tuva
body: Maybe, we can create a util function to create this mock store. Could be useful for other tests and would reduce the amount of code in this test
created_at: 2025-03-27T16:18:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -307,4 +345,140 @@ describe("RecordManager", () => {
       expect(header.textContent).toBe(expectedHeaders[index]);
     });
   });
+
+  describe("Create New Person functionality", () => {
+    it("should show Create New Person button only when matchMode is true", () => {
+      // Set up store with matchMode true and a selected potential match
+      const storeWithMatchMode = {
+        ...mockStore,
+        personMatch: {
+          ...mockStore.personMatch,
+          matchMode: true,
+          selectedPotentialMatchId: "test-match-1",
+          currentPotentialMatches: {
+            "test-match-1": {
+              id: "test-match-1",
+              version: 1,
+              created: new Date(),
+              persons: {},
+              results: [],
+            },
+          },
+        },
+      } as AppStore;
+
+      mockUseAppStore.mockImplementation(
+        <T,>(selector: (state: AppStore) => T): T =>
+          selector(storeWithMatchMode),
+      );
+
+      render(<RecordManager />);
+      expect(screen.getByText("+ Create New Person")).toBeInTheDocument();
+    });
+
+    it("should not show Create New Person button when matchMode is false", () => {
+      // Set up store with matchMode false
+      const storeWithoutMatchMode = {
+        ...mockStore,
+        personMatch: {
+          ...mockStore.personMatch,
+          matchMode: false,
+          selectedPersonId: "test-person-1",
+          currentPersons: {
+            "test-person-1": {
+              id: "test-person-1",
+              version: 1,
+              created: new Date(),
+              records: [],
+            },
+          },
+        },
+      } as AppStore;
+
+      mockUseAppStore.mockImplementation(
+        <T,>(selector: (state: AppStore) => T): T =>
+          selector(storeWithoutMatchMode),
+      );
+
+      render(<RecordManager />);
+      expect(screen.queryByText("+ Create New Person")).not.toBeInTheDocument();
+    });
+
+    it("should create a new person row with 'New Person 1' when button is clicked", async () => {
+      // Set up store with matchMode true and a selected potential match
+      const storeWithMatchMode = {
+        ...mockStore,
+        personMatch: {
+          ...mockStore.personMatch,
+          matchMode: true,
+          selectedPotentialMatchId: "test-match-1",
+          currentPotentialMatches: {
+            "test-match-1": {
+              id: "test-match-1",
+              version: 1,
+              created: new Date(),
+              persons: {},
+              results: [],
+            },
+          },
+        },
+      } as AppStore;
+
+      mockUseAppStore.mockImplementation(
+        <T,>(selector: (state: AppStore) => T): T =>
+          selector(storeWithMatchMode),
+      );
+
+      render(<RecordManager />);
+
+      const createButton = screen.getByText("+ Create New Person");
+      await userEvent.click(createButton);
+
+      expect(mockStore.personMatch.createNewPerson).toHaveBeenCalledWith(
+        "test-match-1",
+      );
+    });
+
+    it("should increment new person number for each additional person created", async () => {
+      // Set up store with matchMode true and a selected potential match with one new person
+      const storeWithNewPerson = {
+        ...mockStore,
+        personMatch: {
+          ...mockStore.personMatch,
+          matchMode: true,
+          selectedPotentialMatchId: "test-match-1",
+          currentPotentialMatches: {
+            "test-match-1": {
+              id: "test-match-1",
+              version: 1,
+              created: new Date(),
+              persons: {
+                "new-person-1": {
+                  id: "new-person-1",
+                  created: new Date(),
+                  records: [],
+                },
+              },
+              results: [],
+            },
+          },
+        },
+      } as AppStore;
+
+      mockUseAppStore.mockImplementation(
+        <T,>(selector: (state: AppStore) => T): T =>
+          selector(storeWithNewPerson),
+      );
+
+      render(<RecordManager />);
+
+      const createButton = screen.getByText("+ Create New Person");
+      await userEvent.click(createButton);
+
+      expect(mockStore.personMatch.createNewPerson).toHaveBeenCalledWith(
+        "test-match-1",
+      );
+      expect(screen.getByText("New Person 1")).toBeInTheDocument();
path: frontend/src/app/person_match/record_manager.test.tsx
user: login: lucas-tuva
body: Don't we expect "New Person 2" here?
created_at: 2025-03-27T16:23:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -202,6 +202,43 @@ describe("PersonRecordRowDetail", () => {
 });
 
 describe("RecordManager", () => {
+  const mockStore = {
+    personMatch: {
+      dataSources: [],
+      matchMode: false,
+      searchTerms: {},
+      potentialMatchSummaries: {},
+      potentialMatches: {},
+      currentPotentialMatches: {},
+      selectedPotentialMatchId: null,
+      personSummaries: {},
+      persons: {},
+      currentPersons: {},
+      selectedPersonId: null,
+      createNewPerson: jest.fn(),
+      fetchDataSources: jest.fn().mockResolvedValue(undefined),
+      setMatchMode: jest.fn(),
+      updateSearchTerms: jest.fn(),
+      clearSearchTerms: jest.fn(),
+      fetchSummaries: jest.fn().mockResolvedValue(undefined),
+      selectSummary: jest.fn(),
+      fetchPotentialMatch: jest.fn().mockResolvedValue(undefined),
+      resetCurrentPotentialMatch: jest.fn(),
+      fetchPerson: jest.fn().mockResolvedValue(undefined),
+      resetCurrentPerson: jest.fn(),
+      movePersonRecord: jest.fn(),
+      matchPersonRecords: jest.fn().mockResolvedValue(undefined),
+      setPersonRecordExpanded: jest.fn(),
+    },
path: frontend/src/app/person_match/record_manager.test.tsx
user: login: karankrishnani
body: Sure, will do
created_at: 2025-03-27T18:51:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -307,4 +345,140 @@ describe("RecordManager", () => {
       expect(header.textContent).toBe(expectedHeaders[index]);
     });
   });
+
+  describe("Create New Person functionality", () => {
+    it("should show Create New Person button only when matchMode is true", () => {
+      // Set up store with matchMode true and a selected potential match
+      const storeWithMatchMode = {
+        ...mockStore,
+        personMatch: {
+          ...mockStore.personMatch,
+          matchMode: true,
+          selectedPotentialMatchId: "test-match-1",
+          currentPotentialMatches: {
+            "test-match-1": {
+              id: "test-match-1",
+              version: 1,
+              created: new Date(),
+              persons: {},
+              results: [],
+            },
+          },
+        },
+      } as AppStore;
+
+      mockUseAppStore.mockImplementation(
+        <T,>(selector: (state: AppStore) => T): T =>
+          selector(storeWithMatchMode),
+      );
+
+      render(<RecordManager />);
+      expect(screen.getByText("+ Create New Person")).toBeInTheDocument();
+    });
+
+    it("should not show Create New Person button when matchMode is false", () => {
+      // Set up store with matchMode false
+      const storeWithoutMatchMode = {
+        ...mockStore,
+        personMatch: {
+          ...mockStore.personMatch,
+          matchMode: false,
+          selectedPersonId: "test-person-1",
+          currentPersons: {
+            "test-person-1": {
+              id: "test-person-1",
+              version: 1,
+              created: new Date(),
+              records: [],
+            },
+          },
+        },
+      } as AppStore;
+
+      mockUseAppStore.mockImplementation(
+        <T,>(selector: (state: AppStore) => T): T =>
+          selector(storeWithoutMatchMode),
+      );
+
+      render(<RecordManager />);
+      expect(screen.queryByText("+ Create New Person")).not.toBeInTheDocument();
+    });
+
+    it("should create a new person row with 'New Person 1' when button is clicked", async () => {
+      // Set up store with matchMode true and a selected potential match
+      const storeWithMatchMode = {
+        ...mockStore,
+        personMatch: {
+          ...mockStore.personMatch,
+          matchMode: true,
+          selectedPotentialMatchId: "test-match-1",
+          currentPotentialMatches: {
+            "test-match-1": {
+              id: "test-match-1",
+              version: 1,
+              created: new Date(),
+              persons: {},
+              results: [],
+            },
+          },
+        },
+      } as AppStore;
+
+      mockUseAppStore.mockImplementation(
+        <T,>(selector: (state: AppStore) => T): T =>
+          selector(storeWithMatchMode),
+      );
+
+      render(<RecordManager />);
+
+      const createButton = screen.getByText("+ Create New Person");
+      await userEvent.click(createButton);
+
+      expect(mockStore.personMatch.createNewPerson).toHaveBeenCalledWith(
+        "test-match-1",
+      );
+    });
+
+    it("should increment new person number for each additional person created", async () => {
+      // Set up store with matchMode true and a selected potential match with one new person
+      const storeWithNewPerson = {
+        ...mockStore,
+        personMatch: {
+          ...mockStore.personMatch,
+          matchMode: true,
+          selectedPotentialMatchId: "test-match-1",
+          currentPotentialMatches: {
+            "test-match-1": {
+              id: "test-match-1",
+              version: 1,
+              created: new Date(),
+              persons: {
+                "new-person-1": {
+                  id: "new-person-1",
+                  created: new Date(),
+                  records: [],
+                },
+              },
+              results: [],
+            },
+          },
+        },
+      } as AppStore;
+
+      mockUseAppStore.mockImplementation(
+        <T,>(selector: (state: AppStore) => T): T =>
+          selector(storeWithNewPerson),
+      );
+
+      render(<RecordManager />);
+
+      const createButton = screen.getByText("+ Create New Person");
+      await userEvent.click(createButton);
+
+      expect(mockStore.personMatch.createNewPerson).toHaveBeenCalledWith(
+        "test-match-1",
+      );
+      expect(screen.getByText("New Person 1")).toBeInTheDocument();
path: frontend/src/app/person_match/record_manager.test.tsx
user: login: karankrishnani
body: Good catch, yes, this should test for both new persons 1 and 2.
created_at: 2025-03-27T18:52:09Z
author_association: CONTRIBUTOR

PR comments:
user: login: lucas-tuva
created_at: 2025-03-27T17:34:43Z
body: https://github.com/tuva-health/tuva_empi/issues/49
author_association: CONTRIBUTOR
user: login: lucas-tuva
created_at: 2025-03-27T18:04:03Z
body: Also, would you say this PR closes: https://github.com/tuva-health/tuva_empi/issues/18 or are there more unit tests you think we should add around that?
author_association: CONTRIBUTOR
user: login: karankrishnani
created_at: 2025-03-28T18:01:44Z
body: > Also, would you say this PR closes: #18 or are there more unit tests you think we should add around that?

Yes the core functionality for matching should be covered.
author_association: CONTRIBUTOR