From af757ee71e32acf169aef2cc9d774a80cf0bb3fc Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 21 Apr 2026 10:28:26 -0400 Subject: [PATCH] hotfix/2026-04-21 - Fix save dirty state on employees causing prompt, add 'save and new' --- .../shop-employees-form.component.jsx | 142 +++++-- .../shop-employees-form.component.test.jsx | 345 ++++++++++++++++++ 2 files changed, 451 insertions(+), 36 deletions(-) create mode 100644 client/src/components/shop-employees/shop-employees-form.component.test.jsx 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 5aed82b8e..8218597b9 100644 --- a/client/src/components/shop-employees/shop-employees-form.component.jsx +++ b/client/src/components/shop-employees/shop-employees-form.component.jsx @@ -4,7 +4,7 @@ import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Switch } from "antd"; import ResponsiveTable from "../responsive-table/responsive-table.component"; import queryString from "query-string"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { useLocation, useNavigate } from "react-router-dom"; @@ -52,6 +52,7 @@ const mapDispatchToProps = () => ({ }); export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDirty }) { + const submitActionRef = useRef("save"); const { t } = useTranslation(); const [internalIsDirty, setInternalIsDirty] = useState(false); const resolvedIsDirty = typeof isDirty === "boolean" ? isDirty : internalIsDirty; @@ -128,55 +129,113 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi }); }, [clearEmployeeFormMeta, currentEmployeeData, form]); + const syncEmployeeFormToSavedData = useCallback( + (employeeData) => { + if (employeeData) { + form.setFieldsValue(employeeData); + } + + window.requestAnimationFrame(() => { + clearEmployeeFormMeta(); + }); + }, + [clearEmployeeFormMeta, form] + ); + useEffect(() => { resetEmployeeFormToCurrentData(); }, [resetEmployeeFormToCurrentData, search.employeeId]); const [updateEmployee] = useMutation(UPDATE_EMPLOYEE); const [insertEmployees] = useMutation(INSERT_EMPLOYEES); + const saveAndResetSubmitAction = useCallback(() => { + const submitAction = submitActionRef.current; + submitActionRef.current = "save"; + return submitAction; + }, []); + const submitEmployeeForm = useCallback( + (submitAction = "save") => { + submitActionRef.current = submitAction; + form.submit(); + }, + [form] + ); + const navigateToEmployee = useCallback( + (employeeId) => { + history({ + search: queryString.stringify({ + ...search, + employeeId + }) + }); + }, + [history, search] + ); + + const handleFinish = async (values) => { + const submitAction = saveAndResetSubmitAction(); + const normalizedValues = { + ...values, + user_email: values.user_email === "" ? null : values.user_email + }; - const handleFinish = (values) => { if (search.employeeId && search.employeeId !== "new") { //Update a record. logImEXEvent("shop_employee_update"); - updateEmployee({ - variables: { - id: search.employeeId, - employee: { - ...values, - user_email: values.user_email === "" ? null : values.user_email + try { + const result = await updateEmployee({ + variables: { + id: search.employeeId, + employee: normalizedValues } - } - }) - .then(() => { - updateDirtyState(false); - void refetch(); - notification.success({ - title: t("employees.successes.save") - }); - }) - .catch((error) => { - notification.error({ - title: t("employees.errors.save", { - message: JSON.stringify(error) - }) - }); }); - } else { - //New record, insert it. - logImEXEvent("shop_employee_insert"); - insertEmployees({ - variables: { employees: [{ ...values, shopid: bodyshop.id }] }, - refetchQueries: ["QUERY_EMPLOYEES"] - }).then((r) => { - updateDirtyState(false); - search.employeeId = r.data.insert_employees.returning[0].id; - history({ search: queryString.stringify(search) }); + syncEmployeeFormToSavedData(result?.data?.update_employees?.returning?.[0] ?? normalizedValues); + void refetch(); + if (submitAction === "saveAndNew") { + navigateToEmployee("new"); + } notification.success({ title: t("employees.successes.save") }); + } catch (error) { + notification.error({ + title: t("employees.errors.save", { + message: JSON.stringify(error) + }) + }); + } + + return; + } + + //New record, insert it. + logImEXEvent("shop_employee_insert"); + + try { + const result = await insertEmployees({ + variables: { employees: [{ ...normalizedValues, shopid: bodyshop.id }] }, + refetchQueries: ["QUERY_EMPLOYEES"] + }); + const savedEmployee = result?.data?.insert_employees?.returning?.[0]; + + syncEmployeeFormToSavedData(savedEmployee ?? normalizedValues); + + if (submitAction === "saveAndNew") { + navigateToEmployee("new"); + } else if (savedEmployee?.id) { + navigateToEmployee(savedEmployee.id); + } + + notification.success({ + title: t("employees.successes.save") + }); + } catch (error) { + notification.error({ + title: t("employees.errors.save", { + message: JSON.stringify(error) + }) }); } }; @@ -240,13 +299,24 @@ export function ShopEmployeesFormComponent({ bodyshop, form, onDirtyChange, isDi form.submit()} disabled={!resolvedIsDirty} style={{ minWidth: 170 }}> - {t("employees.actions.save_employee")} - + + + + } >
{ + const actual = await vi.importActual("@apollo/client/react"); + + return { + ...actual, + useApolloClient: vi.fn(), + useQuery: (...args) => useQueryMock(...args), + useMutation: (...args) => useMutationMock(...args) + }; +}); + +vi.mock("@splitsoftware/splitio-react", () => ({ + useTreatmentsWithConfig: () => ({ + treatments: { + Enhanced_Payroll: { + treatment: "off" + } + } + }) +})); + +vi.mock("react-router-dom", () => ({ + useLocation: () => ({ + search: "?employeeId=new" + }), + useNavigate: () => navigateMock +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key, values = {}) => { + const translations = { + "bodyshop.labels.employee_options": "Employee Options", + "bodyshop.labels.employee_rates": "Employee Rates", + "bodyshop.labels.employee_vacation": "Employee Vacation", + "bodyshop.labels.employees": "Employees", + "employees.actions.addrate": "Add Rate", + "employees.actions.addvacation": "Add Vacation", + "employees.actions.new": "New Employee", + "employees.actions.save_employee": "Save Employee", + "employees.fields.active": "Active", + "employees.fields.employee_number": "Employee Number", + "employees.fields.external_id": "External Id", + "employees.fields.first_name": "First Name", + "employees.fields.flat_rate": "Flat Rate", + "employees.fields.hire_date": "Hire Date", + "employees.fields.last_name": "Last Name", + "employees.fields.pin": "PIN", + "employees.fields.rate": "Rate", + "employees.fields.termination_date": "Termination Date", + "employees.fields.user_email": "User Email", + "employees.labels.active": "Active", + "employees.successes.save": "Saved", + "general.actions.saveandnew": "Save and New", + "general.labels.actions": "Actions" + }; + + if (key === "employees.errors.save") { + return `Save failed: ${values.message ?? ""}`; + } + + if (key === "employees.validation.unique_employee_number") { + return "Employee number must be unique"; + } + + if (key === "bodyshop.validation.useremailmustexist") { + return "User email must exist"; + } + + return translations[key] || key; + } + }) +})); + +vi.mock("../../contexts/Notifications/notificationContext.jsx", () => ({ + useNotification: () => notification +})); + +vi.mock("../../firebase/firebase.utils", () => ({ + logImEXEvent: vi.fn() +})); + +vi.mock("../alert/alert.component", () => ({ + default: ({ title }) =>
{title}
+})); + +vi.mock("../form-fields-changed-alert/form-fields-changed-alert.component.jsx", () => ({ + default: () => null +})); + +vi.mock("../form-date-time-picker/form-date-time-picker.component.jsx", () => ({ + default: ({ id, value, onChange }) => ( + onChange?.(event.target.value)} /> + ) +})); + +vi.mock("../form-items-formatted/email-form-item.component.jsx", () => ({ + default: ({ id, value, onChange }) => ( + onChange?.(event.target.value)} /> + ) +})); + +vi.mock("../layout-form-row/layout-form-row.component", () => ({ + default: ({ title, extra, actions, children }) => ( +
+ {title} + {extra} + {children} + {actions} +
+ ) +})); + +vi.mock("../layout-form-row/inline-validated-form-row.component.jsx", () => ({ + default: ({ title, extra, children }) => ( +
+ {title} + {extra} + {children} +
+ ) +})); + +vi.mock("../layout-form-row/config-list-empty-state.component.jsx", () => ({ + default: ({ actionLabel }) =>
{actionLabel}
+})); + +vi.mock("../form-list-move-arrows/form-list-move-arrows.component", () => ({ + default: () => null +})); + +vi.mock("../responsive-table/responsive-table.component", () => ({ + default: () => null +})); + +vi.mock("./shop-employees-add-vacation.component", () => ({ + default: () => null +})); + +vi.mock("../../utils/Ciecaselect", () => ({ + default: () => [] +})); + +const bodyshop = { + id: "shop-1", + imexshopid: "split-shop-1", + md_responsibility_centers: { + costs: [] + } +}; + +describe("ShopEmployeesFormComponent", () => { + let formInstance; + + beforeEach(() => { + vi.clearAllMocks(); + + useQueryMock.mockImplementation((query) => { + if (query === QUERY_EMPLOYEE_BY_ID) { + return { + error: null, + data: null, + refetch: vi.fn(), + loading: false + }; + } + + return { + error: null, + data: null, + loading: false + }; + }); + + useMutationMock.mockImplementation((mutation) => { + if (mutation === INSERT_EMPLOYEES) return [insertEmployeesMock]; + if (mutation === UPDATE_EMPLOYEE) return [updateEmployeeMock]; + if (mutation === DELETE_VACATION) return [deleteVacationMock]; + return [vi.fn()]; + }); + + useApolloClient.mockReturnValue({ + query: vi.fn().mockResolvedValue({ + data: { + employees_aggregate: { + aggregate: { + count: 0 + }, + nodes: [] + }, + users: [] + } + }) + }); + + insertEmployeesMock.mockResolvedValue({ + data: { + insert_employees: { + returning: [ + { + id: "employee-123", + first_name: "Jamie", + last_name: "Rivera", + employee_number: "42", + active: true, + termination_date: null, + hire_date: "2026-04-20", + flat_rate: false, + rates: [], + pin: "1234", + user_email: null + } + ] + } + } + }); + + function TestHarness({ onFormReady }) { + const [form] = Form.useForm(); + + useEffect(() => { + onFormReady(form); + }, [form, onFormReady]); + + return ; + } + + render( + { + formInstance = form; + }} + /> + ); + }); + + it("marks a new employee form clean after save", async () => { + 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" } + }); + + const saveButton = screen.getByRole("button", { name: "Save Employee" }); + + await waitFor(() => { + expect(saveButton.disabled).toBe(false); + }); + + fireEvent.click(saveButton); + + await waitFor(() => { + expect(insertEmployeesMock).toHaveBeenCalledWith({ + variables: { + employees: [ + expect.objectContaining({ + first_name: "Jamie", + last_name: "Rivera", + employee_number: "42", + pin: "1234", + hire_date: "2026-04-20", + shopid: "shop-1" + }) + ] + }, + refetchQueries: ["QUERY_EMPLOYEES"] + }); + }); + + await waitFor(() => { + expect(formInstance.isFieldsTouched()).toBe(false); + }); + + expect(notification.success).toHaveBeenCalledWith({ + title: "Saved" + }); + expect(navigateMock).toHaveBeenCalledWith({ + search: "employeeId=employee-123" + }); + }); + + it("saves a new employee and opens a fresh employee form when save and new is clicked", async () => { + 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.click(screen.getByRole("button", { name: "Save and New" })); + + await waitFor(() => { + expect(insertEmployeesMock).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(formInstance.isFieldsTouched()).toBe(false); + }); + + expect(navigateMock).toHaveBeenCalledWith({ + search: "employeeId=new" + }); + expect(notification.success).toHaveBeenCalledWith({ + title: "Saved" + }); + }); +});