Skip to content

Commit 27c76fc

Browse files
authored
fix(frontend): normalize embedded user names on detail pages (#5413)
1 parent d3af4d3 commit 27c76fc

12 files changed

Lines changed: 263 additions & 11 deletions

File tree

frontend/src/api/opsAPI.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import { getAccessToken } from "../components/Auth/auth";
33
import { postRefresh } from "./postRefresh.js";
44
import { logout } from "../components/Auth/authSlice.js";
55
import store from "../store";
6-
import { normalizeUser } from "../helpers/users.helpers";
6+
import {
7+
normalizeAgreementUsers,
8+
normalizeCanUsers,
9+
normalizePortfolioUsers,
10+
normalizeProjectUsers,
11+
normalizeUser
12+
} from "../helpers/users.helpers";
713

814
const BACKEND_DOMAIN =
915
(typeof window !== "undefined" && window.__RUNTIME_CONFIG__?.REACT_APP_BACKEND_DOMAIN) ||
@@ -185,6 +191,7 @@ export const opsApi = createApi({
185191
}
186192
return `/agreements/${arg}`;
187193
},
194+
transformResponse: (response) => normalizeAgreementUsers(response),
188195
providesTags: ["Agreements"]
189196
}),
190197
addAgreement: builder.mutation({
@@ -478,6 +485,7 @@ export const opsApi = createApi({
478485
}),
479486
getProjectById: builder.query({
480487
query: (id) => `/projects/${id}`,
488+
transformResponse: (response) => normalizeProjectUsers(response),
481489
providesTags: ["ResearchProjects"]
482490
}),
483491
getProjectsByPortfolio: builder.query({
@@ -497,11 +505,14 @@ export const opsApi = createApi({
497505
},
498506
transformResponse: (response) => {
499507
// New wrapped format with data key
508+
if (Array.isArray(response.data)) {
509+
return response.data.map(normalizeProjectUsers);
510+
}
500511
if (response.data) {
501512
return response.data;
502513
}
503514
// Legacy array format (no wrapper) - for backward compatibility during transition
504-
return response;
515+
return Array.isArray(response) ? response.map(normalizeProjectUsers) : response;
505516
},
506517
providesTags: ["ResearchProjects"]
507518
}),
@@ -681,6 +692,7 @@ export const opsApi = createApi({
681692
}),
682693
getCanById: builder.query({
683694
query: (id) => `/cans/${id}`,
695+
transformResponse: (response) => normalizeCanUsers(response),
684696
providesTags: ["Cans"]
685697
}),
686698
updateCan: builder.mutation({
@@ -835,6 +847,7 @@ export const opsApi = createApi({
835847
}),
836848
getPortfolioById: builder.query({
837849
query: (id) => `/portfolios/${id}`,
850+
transformResponse: (response) => normalizePortfolioUsers(response),
838851
providesTags: ["Portfolios"]
839852
}),
840853
getPortfolioCansById: builder.query({

frontend/src/api/opsAPI.test.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -836,6 +836,30 @@ describe("opsAPI - Wave 2 high-yield endpoint coverage", () => {
836836
expect(capturedUrl).toContain("/agreements/999");
837837
});
838838

839+
it("does not try to map wrapped non-array project responses", async () => {
840+
server.use(
841+
http.get("*/api/v1/projects/", () => {
842+
return HttpResponse.json({
843+
data: {
844+
projects: [{ id: 1, title: "Wrapped Project" }]
845+
}
846+
});
847+
})
848+
);
849+
850+
const storeRef = setupApiStore(opsApi);
851+
const result = await storeRef.store.dispatch(
852+
opsApi.endpoints.getProjectsByPortfolio.initiate({
853+
fiscal_year: 2026,
854+
portfolio_id: 1
855+
})
856+
);
857+
858+
expect(result.data).toEqual({
859+
projects: [{ id: 1, title: "Wrapped Project" }]
860+
});
861+
});
862+
839863
it("builds getAgreementsFilterOptions with only_my", async () => {
840864
let capturedUrl = "";
841865
server.use(

frontend/src/components/CANs/CANDetailView/CANDetailView.jsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Tag from "../../UI/Tag";
22
import Term from "../../UI/Term";
33
import TermTag from "../../UI/Term/TermTag";
4+
import { formatUserName } from "../../../helpers/users.helpers";
45
import CanHistoryPanel from "../CANHistoryPanel";
56
/**
67
* @typedef {Object} CANDetailViewProps
@@ -92,13 +93,13 @@ const CANDetailView = ({
9293
>
9394
<Tag
9495
tagStyle="primaryDarkTextLightBackground"
95-
text={teamLeader.display_name ?? teamLeader.full_name}
96+
text={formatUserName(teamLeader.display_name ?? teamLeader.full_name)}
9697
/>
9798
</dd>
9899
))}
99100
<TermTag
100101
term="Division Director"
101-
description={divisionDirectorFullName}
102+
description={formatUserName(divisionDirectorFullName)}
102103
/>
103104
</dl>
104105
</div>

frontend/src/components/CANs/CANDetailView/CANDetailView.test.jsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,21 @@ describe("CANDetailView", () => {
7777
expect(screen.getByText("Test CAN Description")).toBeInTheDocument();
7878
expect(screen.getByText("Team Leader")).toBeInTheDocument();
7979
});
80+
81+
it("formats all-caps team leaders and division director", () => {
82+
render(
83+
<Provider store={store}>
84+
<CANDetailView
85+
{...mockProps}
86+
teamLeaders={[{ id: 1, full_name: "JOHN DOE", email: "jdoe@example.com" }]}
87+
divisionDirectorFullName="DAVE DIRECTOR"
88+
/>
89+
</Provider>
90+
);
91+
92+
expect(screen.getByText("John Doe")).toBeInTheDocument();
93+
expect(screen.getByText("Dave Director")).toBeInTheDocument();
94+
expect(screen.queryByText("JOHN DOE")).not.toBeInTheDocument();
95+
expect(screen.queryByText("DAVE DIRECTOR")).not.toBeInTheDocument();
96+
});
8097
});

frontend/src/components/UI/TeamLeaders/TeamLeaders.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Tag from "../Tag";
2+
import { formatUserName } from "../../../helpers/users.helpers";
23

34
const TeamLeaders = ({ teamLeaders }) => {
45
if (teamLeaders) {
@@ -15,7 +16,7 @@ const TeamLeaders = ({ teamLeaders }) => {
1516
>
1617
<Tag
1718
tagStyle="primaryDarkTextLightBackground"
18-
text={leader.display_name ?? leader.full_name}
19+
text={formatUserName(leader.display_name ?? leader.full_name)}
1920
/>
2021
</dd>
2122
))}

frontend/src/components/UI/TeamLeaders/TeamLeaders.test.jsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ describe("TeamLeaders", () => {
2121
expect(screen.getByText("TBD")).toBeInTheDocument();
2222
});
2323

24+
it("should format all-caps team leader names", () => {
25+
render(<TeamLeaders teamLeaders={[{ id: 1, full_name: "JANE SMITH" }]} />);
26+
27+
expect(screen.getByText("Jane Smith")).toBeInTheDocument();
28+
expect(screen.queryByText("JANE SMITH")).not.toBeInTheDocument();
29+
});
30+
2431
it("should not render anything when teamLeaders is undefined", () => {
2532
const { container } = render(<TeamLeaders teamLeaders={undefined} />);
2633
expect(container).toBeEmptyDOMElement();

frontend/src/helpers/users.helpers.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,79 @@ export const normalizeUser = (user) => {
116116
display_name: getUserDisplayName(user)
117117
};
118118
};
119+
120+
/**
121+
* Normalizes an array of user-like objects by attaching a formatted `display_name`.
122+
* @param {Array<Object> | null | undefined} users
123+
* @returns {*} The normalized array, or the original non-array input unchanged.
124+
*/
125+
export const normalizeUsers = (users) => {
126+
if (!Array.isArray(users)) return users;
127+
return users.map(normalizeUser);
128+
};
129+
130+
/**
131+
* Normalizes an array of raw name strings for display.
132+
* @param {Array<string> | null | undefined} names
133+
* @returns {*} The normalized array, or the original non-array input unchanged.
134+
*/
135+
export const normalizeNameStrings = (names) => {
136+
if (!Array.isArray(names)) return names;
137+
return names.map((name) => formatUserName(name));
138+
};
139+
140+
/**
141+
* Normalizes embedded agreement people fields used by the UI.
142+
* @param {Object | null | undefined} agreement
143+
* @returns {Object | null | undefined}
144+
*/
145+
export const normalizeAgreementUsers = (agreement) => {
146+
if (!agreement || typeof agreement !== "object") return agreement;
147+
return {
148+
...agreement,
149+
team_members: normalizeUsers(agreement.team_members),
150+
team_leaders: normalizeNameStrings(agreement.team_leaders),
151+
division_directors: normalizeNameStrings(agreement.division_directors)
152+
};
153+
};
154+
155+
/**
156+
* Normalizes embedded project people fields used by the UI.
157+
* @param {Object | null | undefined} project
158+
* @returns {Object | null | undefined}
159+
*/
160+
export const normalizeProjectUsers = (project) => {
161+
if (!project || typeof project !== "object") return project;
162+
return {
163+
...project,
164+
team_leaders: normalizeUsers(project.team_leaders),
165+
team_members: normalizeUsers(project.team_members),
166+
division_directors: normalizeNameStrings(project.division_directors)
167+
};
168+
};
169+
170+
/**
171+
* Normalizes embedded portfolio people fields used by the UI.
172+
* @param {Object | null | undefined} portfolio
173+
* @returns {Object | null | undefined}
174+
*/
175+
export const normalizePortfolioUsers = (portfolio) => {
176+
if (!portfolio || typeof portfolio !== "object") return portfolio;
177+
return {
178+
...portfolio,
179+
team_leaders: normalizeUsers(portfolio.team_leaders)
180+
};
181+
};
182+
183+
/**
184+
* Normalizes embedded CAN people fields used by the UI.
185+
* @param {Object | null | undefined} can
186+
* @returns {Object | null | undefined}
187+
*/
188+
export const normalizeCanUsers = (can) => {
189+
if (!can || typeof can !== "object") return can;
190+
return {
191+
...can,
192+
portfolio: normalizePortfolioUsers(can.portfolio)
193+
};
194+
};

frontend/src/helpers/users.helpers.test.js

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import { describe, it, expect } from "vitest";
2-
import { formatUserName, getUserDisplayName, normalizeUser } from "./users.helpers";
2+
import {
3+
formatUserName,
4+
getUserDisplayName,
5+
normalizeAgreementUsers,
6+
normalizeCanUsers,
7+
normalizePortfolioUsers,
8+
normalizeProjectUsers,
9+
normalizeUser
10+
} from "./users.helpers";
311

412
describe("formatUserName", () => {
513
// Already mixed-case — leave untouched
@@ -134,3 +142,69 @@ describe("normalizeUser", () => {
134142
expect(normalizeUser(user)).toEqual({ ...user, display_name: "Jane Doe" });
135143
});
136144
});
145+
146+
describe("embedded payload normalizers", () => {
147+
it("normalizes agreement string name arrays and team members", () => {
148+
expect(
149+
normalizeAgreementUsers({
150+
division_directors: ["DAVE DIRECTOR"],
151+
team_leaders: ["CHRIS FORTUNATO"],
152+
team_members: [{ id: 1, full_name: "AMELIA POPHAM", email: "amelia@example.com" }]
153+
})
154+
).toEqual({
155+
division_directors: ["Dave Director"],
156+
team_leaders: ["Chris Fortunato"],
157+
team_members: [
158+
{ id: 1, full_name: "AMELIA POPHAM", email: "amelia@example.com", display_name: "Amelia Popham" }
159+
]
160+
});
161+
});
162+
163+
it("normalizes project team leaders, team members, and division directors", () => {
164+
expect(
165+
normalizeProjectUsers({
166+
division_directors: ["DIRECTOR DERREK"],
167+
team_leaders: [{ id: 1, full_name: "CHRIS FORTUNATO", email: "chris@example.com" }],
168+
team_members: [{ id: 2, full_name: "SYSTEM OWNER", email: "owner@example.com" }]
169+
})
170+
).toEqual({
171+
division_directors: ["Director Derrek"],
172+
team_leaders: [
173+
{ id: 1, full_name: "CHRIS FORTUNATO", email: "chris@example.com", display_name: "Chris Fortunato" }
174+
],
175+
team_members: [
176+
{ id: 2, full_name: "SYSTEM OWNER", email: "owner@example.com", display_name: "System Owner" }
177+
]
178+
});
179+
});
180+
181+
it("normalizes portfolio team leaders", () => {
182+
expect(
183+
normalizePortfolioUsers({
184+
id: 1,
185+
team_leaders: [{ id: 1, full_name: "JANE SMITH", email: "jane@example.com" }]
186+
})
187+
).toEqual({
188+
id: 1,
189+
team_leaders: [{ id: 1, full_name: "JANE SMITH", email: "jane@example.com", display_name: "Jane Smith" }]
190+
});
191+
});
192+
193+
it("normalizes nested portfolio team leaders on CAN payloads", () => {
194+
expect(
195+
normalizeCanUsers({
196+
id: 1,
197+
portfolio: {
198+
id: 2,
199+
team_leaders: [{ id: 1, full_name: "JOHN DOE", email: "john@example.com" }]
200+
}
201+
})
202+
).toEqual({
203+
id: 1,
204+
portfolio: {
205+
id: 2,
206+
team_leaders: [{ id: 1, full_name: "JOHN DOE", email: "john@example.com", display_name: "John Doe" }]
207+
}
208+
});
209+
});
210+
});

frontend/src/pages/agreements/details/AgreementDetailsView.jsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import AgreementHistoryPanel from "../../../components/Agreements/AgreementDetai
22
import Tag from "../../../components/UI/Tag/Tag";
33
import { NO_DATA } from "../../../constants";
44
import { getAgreementType, getFundingMethod, getPartnerType, isFieldVisible } from "../../../helpers/agreement.helpers";
5+
import { formatUserName } from "../../../helpers/users.helpers";
56
import { convertCodeForDisplay } from "../../../helpers/utils";
67
import { AGREEMENT_NICKNAME_LABEL, AgreementFields } from "../agreements.constants";
78

@@ -392,7 +393,7 @@ const AgreementDetailsView = ({ agreement, projectOfficer, alternateProjectOffic
392393
<Tag
393394
dataCy="division-director-tag"
394395
tagStyle="primaryDarkTextLightBackground"
395-
text={director}
396+
text={formatUserName(director)}
396397
/>
397398
</dd>
398399
))}
@@ -424,7 +425,7 @@ const AgreementDetailsView = ({ agreement, projectOfficer, alternateProjectOffic
424425
<Tag
425426
dataCy="team-leader-tag"
426427
tagStyle="primaryDarkTextLightBackground"
427-
text={leader}
428+
text={formatUserName(leader)}
428429
/>
429430
</dd>
430431
))}

frontend/src/pages/agreements/details/AgreementDetailsView.test.jsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,4 +195,24 @@ describe("AgreementDetailsView", () => {
195195
expect(screen.getByText("IS_AWARDED_TEST")).toBeInTheDocument();
196196
expect(screen.getByText("Contract #")).toBeInTheDocument();
197197
});
198+
199+
it("formats all-caps division directors and team leaders for display", () => {
200+
render(
201+
<AgreementDetailsView
202+
agreement={{
203+
...agreement,
204+
division_directors: ["DAVE DIRECTOR"],
205+
team_leaders: ["CHRIS FORTUNATO"]
206+
}}
207+
projectOfficer={mockProjectOfficer}
208+
alternateProjectOfficer={null}
209+
isAgreementAwarded={false}
210+
/>
211+
);
212+
213+
expect(screen.getAllByText("Dave Director").length).toBeGreaterThan(0);
214+
expect(screen.getAllByText("Chris Fortunato").length).toBeGreaterThan(0);
215+
expect(screen.queryByText("DAVE DIRECTOR")).not.toBeInTheDocument();
216+
expect(screen.queryByText("CHRIS FORTUNATO")).not.toBeInTheDocument();
217+
});
198218
});

0 commit comments

Comments
 (0)