hotfix/2026-04-21 - Fix save dirty state on employees causing prompt, add 'save and new'
This commit is contained in:
@@ -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
|
||||
<Card
|
||||
title={employeeCardTitle}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => form.submit()} disabled={!resolvedIsDirty} style={{ minWidth: 170 }}>
|
||||
{t("employees.actions.save_employee")}
|
||||
</Button>
|
||||
<Space wrap>
|
||||
<Button onClick={() => submitEmployeeForm("saveAndNew")} disabled={!resolvedIsDirty} style={{ minWidth: 170 }}>
|
||||
{t("general.actions.saveandnew") || "Save and New"}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => submitEmployeeForm("save")}
|
||||
disabled={!resolvedIsDirty}
|
||||
style={{ minWidth: 170 }}
|
||||
>
|
||||
{t("employees.actions.save_employee")}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
onFinish={handleFinish}
|
||||
onFinishFailed={saveAndResetSubmitAction}
|
||||
autoComplete={"off"}
|
||||
layout="vertical"
|
||||
form={form}
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
import { useApolloClient } from "@apollo/client/react";
|
||||
import { Form } from "antd";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useEffect } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DELETE_VACATION, INSERT_EMPLOYEES, QUERY_EMPLOYEE_BY_ID, UPDATE_EMPLOYEE } from "../../graphql/employees.queries";
|
||||
import { ShopEmployeesFormComponent } from "./shop-employees-form.component.jsx";
|
||||
|
||||
const insertEmployeesMock = vi.fn();
|
||||
const updateEmployeeMock = vi.fn();
|
||||
const deleteVacationMock = vi.fn();
|
||||
const useQueryMock = vi.fn();
|
||||
const useMutationMock = vi.fn();
|
||||
const navigateMock = vi.fn();
|
||||
const notification = {
|
||||
error: vi.fn(),
|
||||
success: vi.fn()
|
||||
};
|
||||
|
||||
vi.mock("@apollo/client/react", async () => {
|
||||
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 }) => <div>{title}</div>
|
||||
}));
|
||||
|
||||
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 }) => (
|
||||
<input id={id} type="text" value={value ?? ""} onChange={(event) => onChange?.(event.target.value)} />
|
||||
)
|
||||
}));
|
||||
|
||||
vi.mock("../form-items-formatted/email-form-item.component.jsx", () => ({
|
||||
default: ({ id, value, onChange }) => (
|
||||
<input id={id} type="email" value={value ?? ""} onChange={(event) => onChange?.(event.target.value)} />
|
||||
)
|
||||
}));
|
||||
|
||||
vi.mock("../layout-form-row/layout-form-row.component", () => ({
|
||||
default: ({ title, extra, actions, children }) => (
|
||||
<div>
|
||||
{title}
|
||||
{extra}
|
||||
{children}
|
||||
{actions}
|
||||
</div>
|
||||
)
|
||||
}));
|
||||
|
||||
vi.mock("../layout-form-row/inline-validated-form-row.component.jsx", () => ({
|
||||
default: ({ title, extra, children }) => (
|
||||
<div>
|
||||
{title}
|
||||
{extra}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}));
|
||||
|
||||
vi.mock("../layout-form-row/config-list-empty-state.component.jsx", () => ({
|
||||
default: ({ actionLabel }) => <div>{actionLabel}</div>
|
||||
}));
|
||||
|
||||
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 <ShopEmployeesFormComponent bodyshop={bodyshop} form={form} />;
|
||||
}
|
||||
|
||||
render(
|
||||
<TestHarness
|
||||
onFormReady={(form) => {
|
||||
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"
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user