From c40fea0ec9b57fa25bd1c2abf0601f2ab5b84744 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 26 May 2026 12:17:25 -0400 Subject: [PATCH] feature/IO-2960-Employee-Email-Info - Fix --- .../shop-employees-form.component.jsx | 24 ++++-- .../shop-employees-form.component.test.jsx | 85 ++++++++++++++++--- client/src/graphql/employees.queries.js | 16 ++++ client/src/translations/en_us/common.json | 3 +- client/src/translations/es/common.json | 3 +- client/src/translations/fr/common.json | 3 +- 6 files changed, 114 insertions(+), 20 deletions(-) diff --git a/client/src/components/shop-employees/shop-employees-form.component.jsx b/client/src/components/shop-employees/shop-employees-form.component.jsx index 3d0b99e57..7fafee1eb 100644 --- a/client/src/components/shop-employees/shop-employees-form.component.jsx +++ b/client/src/components/shop-employees/shop-employees-form.component.jsx @@ -12,11 +12,11 @@ import { createStructuredSelector } from "reselect"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; import { logImEXEvent } from "../../firebase/firebase.utils"; import { + CHECK_EMPLOYEE_EMAIL, CHECK_EMPLOYEE_NUMBER, DELETE_VACATION, INSERT_EMPLOYEES, QUERY_EMPLOYEE_BY_ID, - QUERY_USERS_BY_EMAIL, UPDATE_EMPLOYEE } from "../../graphql/employees.queries"; import { selectBodyshop } from "../../redux/user/user.selectors"; @@ -174,9 +174,10 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi const handleFinish = async (values) => { const submitAction = saveAndResetSubmitAction(); + const userEmail = typeof values.user_email === "string" ? values.user_email.trim() : values.user_email; const normalizedValues = { ...values, - user_email: values.user_email === "" ? null : values.user_email + user_email: userEmail === "" ? null : userEmail }; if (search.employeeId && search.employeeId !== "new") { @@ -491,18 +492,29 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi rules={[ ({ getFieldValue }) => ({ async validator(rule, value) { - const user_email = getFieldValue("user_email"); + const user_email = typeof value === "string" ? value.trim() : getFieldValue("user_email"); if (user_email && value) { const response = await client.query({ - query: QUERY_USERS_BY_EMAIL, + query: CHECK_EMPLOYEE_EMAIL, variables: { - email: user_email + email: user_email, + shopId: bodyshop.id } }); if (response.data.users.length === 1) { - return Promise.resolve(); + const matchingEmployees = response.data.employees_aggregate.nodes; + const currentEmployeeId = form.getFieldValue("id") ?? search.employeeId; + + if ( + response.data.employees_aggregate.aggregate.count === 0 || + matchingEmployees.every((employee) => employee.id === currentEmployeeId) + ) { + return Promise.resolve(); + } + + return Promise.reject(t("employees.validation.unique_user_email")); } return Promise.reject(t("bodyshop.validation.useremailmustexist")); } else { diff --git a/client/src/components/shop-employees/shop-employees-form.component.test.jsx b/client/src/components/shop-employees/shop-employees-form.component.test.jsx index 1d2c41f70..fdca7762a 100644 --- a/client/src/components/shop-employees/shop-employees-form.component.test.jsx +++ b/client/src/components/shop-employees/shop-employees-form.component.test.jsx @@ -4,6 +4,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { useEffect } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { + CHECK_EMPLOYEE_EMAIL, DELETE_VACATION, INSERT_EMPLOYEES, QUERY_EMPLOYEE_BY_ID, @@ -16,6 +17,7 @@ const updateEmployeeMock = vi.fn(); const deleteVacationMock = vi.fn(); const useQueryMock = vi.fn(); const useMutationMock = vi.fn(); +const apolloClientQueryMock = vi.fn(); const navigateMock = vi.fn(); const notification = { error: vi.fn(), @@ -87,6 +89,10 @@ vi.mock("react-i18next", () => ({ return "Employee number must be unique"; } + if (key === "employees.validation.unique_user_email") { + return "User email already assigned"; + } + if (key === "bodyshop.validation.useremailmustexist") { return "User email must exist"; } @@ -203,18 +209,20 @@ describe("ShopEmployeesFormComponent", () => { return [vi.fn()]; }); - useApolloClient.mockReturnValue({ - query: vi.fn().mockResolvedValue({ - data: { - employees_aggregate: { - aggregate: { - count: 0 - }, - nodes: [] + apolloClientQueryMock.mockResolvedValue({ + data: { + employees_aggregate: { + aggregate: { + count: 0 }, - users: [] - } - }) + nodes: [] + }, + users: [] + } + }); + + useApolloClient.mockReturnValue({ + query: apolloClientQueryMock }); insertEmployeesMock.mockResolvedValue({ @@ -356,4 +364,59 @@ describe("ShopEmployeesFormComponent", () => { title: "Saved" }); }); + + it("blocks saving when the user email belongs to another employee in the shop", async () => { + apolloClientQueryMock.mockImplementation(({ query }) => { + if (query === CHECK_EMPLOYEE_EMAIL) { + return Promise.resolve({ + data: { + users: [{ email: "jamie@example.com" }], + employees_aggregate: { + aggregate: { + count: 1 + }, + nodes: [{ id: "other-employee" }] + } + } + }); + } + + return Promise.resolve({ + data: { + employees_aggregate: { + aggregate: { + count: 0 + }, + nodes: [] + }, + users: [] + } + }); + }); + + fireEvent.change(screen.getByRole("textbox", { name: "First Name" }), { + target: { value: "Jamie" } + }); + fireEvent.change(screen.getByRole("textbox", { name: "Last Name" }), { + target: { value: "Rivera" } + }); + fireEvent.change(screen.getByRole("textbox", { name: "Employee Number" }), { + target: { value: "42" } + }); + fireEvent.change(screen.getByRole("textbox", { name: "PIN" }), { + target: { value: "1234" } + }); + fireEvent.change(screen.getByRole("textbox", { name: "Hire Date" }), { + target: { value: "2026-04-20" } + }); + fireEvent.change(screen.getByRole("textbox", { name: "User Email" }), { + target: { value: "jamie@example.com" } + }); + + fireEvent.click(screen.getByRole("button", { name: "Save Employee" })); + + expect(await screen.findByText("User email already assigned")).toBeInTheDocument(); + expect(insertEmployeesMock).not.toHaveBeenCalled(); + expect(notification.success).not.toHaveBeenCalled(); + }); }); diff --git a/client/src/graphql/employees.queries.js b/client/src/graphql/employees.queries.js index 5b73db825..da561c1b7 100644 --- a/client/src/graphql/employees.queries.js +++ b/client/src/graphql/employees.queries.js @@ -49,6 +49,22 @@ export const CHECK_EMPLOYEE_NUMBER = gql` } `; +export const CHECK_EMPLOYEE_EMAIL = gql` + query CHECK_EMPLOYEE_EMAIL($email: String!, $shopId: uuid!) { + users(where: { email: { _ilike: $email } }) { + email + } + employees_aggregate(where: { user_email: { _ilike: $email }, shopid: { _eq: $shopId } }) { + aggregate { + count + } + nodes { + id + } + } + } +`; + export const QUERY_ACTIVE_EMPLOYEES = gql` query QUERY_ACTIVE_EMPLOYEES { employees(where: { active: { _eq: true } }) { diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 4494f75af..124725363 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -1350,7 +1350,8 @@ "vacationadded": "Employee vacation added." }, "validation": { - "unique_employee_number": "You must enter a unique employee number." + "unique_employee_number": "You must enter a unique employee number.", + "unique_user_email": "This email is already assigned to another employee." } }, "esignature": { diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index 71e7e7dc6..7412f5c56 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -1350,7 +1350,8 @@ "vacationadded": "" }, "validation": { - "unique_employee_number": "" + "unique_employee_number": "", + "unique_user_email": "Este correo electrónico ya está asignado a otro empleado." } }, "esignature": { diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index eec55cea4..14a2c3282 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -1350,7 +1350,8 @@ "vacationadded": "" }, "validation": { - "unique_employee_number": "" + "unique_employee_number": "", + "unique_user_email": "Cette adresse courriel est déjà assignée à un autre employé." } }, "esignature": {