IO-3624 Finalize admin config UX and validation polish
This commit is contained in:
@@ -3,7 +3,7 @@ import { useMutation, useQuery } from "@apollo/client/react";
|
||||
import { Button, Card, Col, Form, Input, InputNumber, Row, Select, Skeleton, Space, Switch, Typography } from "antd";
|
||||
|
||||
import querystring from "query-string";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { connect } from "react-redux";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
@@ -11,9 +11,11 @@ import { createStructuredSelector } from "reselect";
|
||||
import { logImEXEvent } from "../../firebase/firebase.utils";
|
||||
import { selectBodyshop } from "../../redux/user/user.selectors";
|
||||
import AlertComponent from "../alert/alert.component";
|
||||
import FormsFieldChanged from "../form-fields-changed-alert/form-fields-changed-alert.component";
|
||||
import CurrencyInput from "../form-items-formatted/currency-form-item.component";
|
||||
import FormListMoveArrows from "../form-list-move-arrows/form-list-move-arrows.component";
|
||||
import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.component.jsx";
|
||||
import InlineValidatedFormRow from "../layout-form-row/inline-validated-form-row.component.jsx";
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
import {
|
||||
INLINE_TITLE_GROUP_STYLE,
|
||||
@@ -61,16 +63,19 @@ const formatAllocationPercentage = (percentage) => {
|
||||
return `${numericValue.toFixed(2).replace(/\.?0+$/, "")}%`;
|
||||
};
|
||||
|
||||
export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
||||
export function ShopEmployeeTeamsFormComponent({ bodyshop, form, onDirtyChange, isDirty }) {
|
||||
const { t } = useTranslation();
|
||||
const [form] = Form.useForm();
|
||||
const [internalForm] = Form.useForm();
|
||||
const [internalIsDirty, setInternalIsDirty] = useState(false);
|
||||
const teamForm = form ?? internalForm;
|
||||
const resolvedIsDirty = typeof isDirty === "boolean" ? isDirty : internalIsDirty;
|
||||
const history = useNavigate();
|
||||
const search = querystring.parse(useLocation().search);
|
||||
const notification = useNotification();
|
||||
const [hydratedTeamId, setHydratedTeamId] = useState(search.employeeTeamId === "new" ? "new" : null);
|
||||
const isNewTeam = search.employeeTeamId === "new";
|
||||
|
||||
const { error, data, loading } = useQuery(QUERY_EMPLOYEE_TEAM_BY_ID, {
|
||||
const { error, data, loading, refetch } = useQuery(QUERY_EMPLOYEE_TEAM_BY_ID, {
|
||||
variables: { id: search.employeeTeamId },
|
||||
skip: !search.employeeTeamId || isNewTeam,
|
||||
fetchPolicy: "network-only",
|
||||
@@ -78,38 +83,71 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
||||
notifyOnNetworkStatusChange: true
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!search.employeeTeamId) return;
|
||||
const currentTeamData = data?.employee_teams_by_pk?.id === search.employeeTeamId ? data.employee_teams_by_pk : null;
|
||||
|
||||
const updateDirtyState = useCallback(
|
||||
(nextDirtyState) => {
|
||||
if (typeof isDirty !== "boolean") {
|
||||
setInternalIsDirty(nextDirtyState);
|
||||
}
|
||||
|
||||
onDirtyChange?.(nextDirtyState);
|
||||
},
|
||||
[isDirty, onDirtyChange]
|
||||
);
|
||||
|
||||
const clearTeamFormMeta = useCallback(() => {
|
||||
const fieldMeta = teamForm.getFieldsError().map(({ name }) => ({
|
||||
name,
|
||||
touched: false,
|
||||
validating: false,
|
||||
errors: [],
|
||||
warnings: []
|
||||
}));
|
||||
|
||||
if (fieldMeta.length > 0) {
|
||||
teamForm.setFields(fieldMeta);
|
||||
}
|
||||
|
||||
updateDirtyState(false);
|
||||
}, [teamForm, updateDirtyState]);
|
||||
|
||||
const resetTeamFormToCurrentData = useCallback(() => {
|
||||
let hydrationFrameId;
|
||||
|
||||
teamForm.resetFields();
|
||||
|
||||
if (isNewTeam) {
|
||||
form.resetFields();
|
||||
setHydratedTeamId("new");
|
||||
return;
|
||||
hydrationFrameId = window.requestAnimationFrame(() => {
|
||||
clearTeamFormMeta();
|
||||
});
|
||||
return () => {
|
||||
if (hydrationFrameId) window.cancelAnimationFrame(hydrationFrameId);
|
||||
};
|
||||
}
|
||||
|
||||
setHydratedTeamId(null);
|
||||
}, [form, isNewTeam, search.employeeTeamId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!search.employeeTeamId || isNewTeam || loading) return;
|
||||
let hydrationFrameId;
|
||||
|
||||
if (data?.employee_teams_by_pk?.id === search.employeeTeamId) {
|
||||
form.setFieldsValue(normalizeEmployeeTeam(data.employee_teams_by_pk));
|
||||
hydrationFrameId = window.requestAnimationFrame(() => {
|
||||
setHydratedTeamId(search.employeeTeamId);
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
hydrationFrameId = window.requestAnimationFrame(() => {
|
||||
setHydratedTeamId(search.employeeTeamId);
|
||||
});
|
||||
if (loading) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (currentTeamData) {
|
||||
teamForm.setFieldsValue(normalizeEmployeeTeam(currentTeamData));
|
||||
}
|
||||
|
||||
hydrationFrameId = window.requestAnimationFrame(() => {
|
||||
setHydratedTeamId(search.employeeTeamId);
|
||||
clearTeamFormMeta();
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (hydrationFrameId) window.cancelAnimationFrame(hydrationFrameId);
|
||||
};
|
||||
}, [data, form, isNewTeam, loading, search.employeeTeamId]);
|
||||
}, [clearTeamFormMeta, currentTeamData, isNewTeam, loading, search.employeeTeamId, teamForm]);
|
||||
|
||||
useEffect(() => resetTeamFormToCurrentData(), [resetTeamFormToCurrentData]);
|
||||
|
||||
const [updateEmployeeTeam] = useMutation(UPDATE_EMPLOYEE_TEAM);
|
||||
const [insertEmployeeTeam] = useMutation(INSERT_EMPLOYEE_TEAM);
|
||||
@@ -117,8 +155,8 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
||||
label: t(labelKey),
|
||||
value
|
||||
}));
|
||||
const teamName = Form.useWatch("name", form);
|
||||
const teamMembers = Form.useWatch(["employee_team_members"], form) || [];
|
||||
const teamName = Form.useWatch("name", teamForm);
|
||||
const teamMembers = Form.useWatch(["employee_team_members"], teamForm) || [];
|
||||
const isTeamHydrating = !isNewTeam && Boolean(search.employeeTeamId) && hydratedTeamId !== search.employeeTeamId;
|
||||
const isAllocationTotalExact = hasExactSplitTotal(teamMembers);
|
||||
const allocationTotalValue = formatAllocationPercentage(getSplitTotal(teamMembers))?.replace("%", "") || "0";
|
||||
@@ -172,6 +210,8 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
||||
});
|
||||
|
||||
if (!result.errors) {
|
||||
updateDirtyState(false);
|
||||
void refetch();
|
||||
notification.success({
|
||||
title: t("employees.successes.save")
|
||||
});
|
||||
@@ -195,6 +235,7 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
||||
},
|
||||
refetchQueries: ["QUERY_TEAMS"]
|
||||
}).then((response) => {
|
||||
updateDirtyState(false);
|
||||
search.employeeTeamId = response.data.insert_employee_teams_one.id;
|
||||
history({ search: querystring.stringify(search) });
|
||||
notification.success({
|
||||
@@ -211,7 +252,12 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
||||
<Card
|
||||
title={isTeamHydrating ? undefined : teamCardTitle}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => form.submit()} disabled={isTeamHydrating} style={{ minWidth: 190 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => teamForm.submit()}
|
||||
disabled={isTeamHydrating || !resolvedIsDirty}
|
||||
style={{ minWidth: 190 }}
|
||||
>
|
||||
{t("employee_teams.actions.save_team")}
|
||||
</Button>
|
||||
}
|
||||
@@ -219,7 +265,16 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
||||
{isTeamHydrating ? (
|
||||
<Skeleton active title={false} paragraph={{ rows: 12 }} />
|
||||
) : (
|
||||
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
|
||||
<Form
|
||||
onFinish={handleFinish}
|
||||
autoComplete={"off"}
|
||||
layout="vertical"
|
||||
form={teamForm}
|
||||
onValuesChange={() => {
|
||||
updateDirtyState(teamForm.isFieldsTouched());
|
||||
}}
|
||||
>
|
||||
<FormsFieldChanged form={teamForm} onReset={resetTeamFormToCurrentData} onDirtyChange={updateDirtyState} />
|
||||
<LayoutFormRow
|
||||
title={
|
||||
<div
|
||||
@@ -307,11 +362,17 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
||||
) : (
|
||||
fields.map((field, index) => {
|
||||
return (
|
||||
<Form.Item key={field.key} style={{ padding: 0, margin: 2 }}>
|
||||
<Form.Item noStyle key={field.key}>
|
||||
<Form.Item name={[field.name, "id"]} hidden>
|
||||
<Input type="hidden" />
|
||||
</Form.Item>
|
||||
<LayoutFormRow
|
||||
<InlineValidatedFormRow
|
||||
form={teamForm}
|
||||
errorNames={[
|
||||
["employee_team_members", field.name, "employeeid"],
|
||||
["employee_team_members", field.name, "percentage"],
|
||||
["employee_team_members", field.name, "payout_method"]
|
||||
]}
|
||||
grow
|
||||
title={
|
||||
<div style={INLINE_TITLE_ROW_STYLE}>
|
||||
@@ -410,7 +471,7 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
||||
>
|
||||
{() => {
|
||||
const payoutMethod =
|
||||
form.getFieldValue(["employee_team_members", field.name, "payout_method"]) ||
|
||||
teamForm.getFieldValue(["employee_team_members", field.name, "payout_method"]) ||
|
||||
"hourly";
|
||||
const fieldName = payoutMethod === "commission" ? "commission_rates" : "labor_rates";
|
||||
|
||||
@@ -443,7 +504,7 @@ export function ShopEmployeeTeamsFormComponent({ bodyshop }) {
|
||||
}}
|
||||
</Form.Item>
|
||||
</div>
|
||||
</LayoutFormRow>
|
||||
</InlineValidatedFormRow>
|
||||
</Form.Item>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -68,6 +68,10 @@ vi.mock("../../contexts/Notifications/notificationContext.jsx", () => ({
|
||||
useNotification: () => notification
|
||||
}));
|
||||
|
||||
vi.mock("../form-fields-changed-alert/form-fields-changed-alert.component.jsx", () => ({
|
||||
default: () => null
|
||||
}));
|
||||
|
||||
vi.mock("../../firebase/firebase.utils", () => ({
|
||||
logImEXEvent: vi.fn()
|
||||
}));
|
||||
|
||||
@@ -6,12 +6,22 @@ import ConfigListEmptyState from "../layout-form-row/config-list-empty-state.com
|
||||
import LayoutFormRow from "../layout-form-row/layout-form-row.component";
|
||||
import ResponsiveTable from "../responsive-table/responsive-table.component";
|
||||
|
||||
export default function ShopEmployeeTeamsListComponent({ loading, employee_teams }) {
|
||||
export default function ShopEmployeeTeamsListComponent({
|
||||
loading,
|
||||
employee_teams,
|
||||
onRequestTeamChange,
|
||||
selectedTeamId
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const history = useNavigate();
|
||||
const search = queryString.parse(useLocation().search);
|
||||
|
||||
const navigateToTeam = (employeeTeamId) => {
|
||||
if (onRequestTeamChange) {
|
||||
onRequestTeamChange(employeeTeamId);
|
||||
return;
|
||||
}
|
||||
|
||||
history({
|
||||
search: queryString.stringify({
|
||||
...search,
|
||||
@@ -65,7 +75,7 @@ export default function ShopEmployeeTeamsListComponent({ loading, employee_teams
|
||||
rowSelection={{
|
||||
onSelect: (props) => navigateToTeam(props.id),
|
||||
type: "radio",
|
||||
selectedRowKeys: [search.employeeTeamId]
|
||||
selectedRowKeys: [selectedTeamId || search.employeeTeamId]
|
||||
}}
|
||||
onRow={(record) => {
|
||||
return {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Form } from "antd";
|
||||
import { useQuery } from "@apollo/client/react";
|
||||
import queryString from "query-string";
|
||||
import { connect } from "react-redux";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { createStructuredSelector } from "reselect";
|
||||
import { QUERY_TEAMS } from "../../graphql/employee_teams.queries";
|
||||
import useConfirmDirtyFormNavigation from "../../hooks/useConfirmDirtyFormNavigation.jsx";
|
||||
import AlertComponent from "../alert/alert.component";
|
||||
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
|
||||
import ShopEmployeeTeamsListComponent from "./shop-employee-teams.list";
|
||||
@@ -13,12 +16,30 @@ import "./shop-teams.styles.scss";
|
||||
const mapStateToProps = createStructuredSelector({});
|
||||
|
||||
function ShopTeamsContainer() {
|
||||
const [form] = Form.useForm();
|
||||
const [isTeamFormDirty, setIsTeamFormDirty] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const search = queryString.parse(useLocation().search);
|
||||
const { loading, error, data } = useQuery(QUERY_TEAMS, {
|
||||
fetchPolicy: "network-only",
|
||||
nextFetchPolicy: "network-only"
|
||||
});
|
||||
const hasSelectedTeam = Boolean(search.employeeTeamId);
|
||||
const hasDirtyTeamForm = Boolean(search.employeeTeamId) && isTeamFormDirty;
|
||||
const confirmCloseDirtyTeam = useConfirmDirtyFormNavigation(hasDirtyTeamForm);
|
||||
|
||||
const navigateToTeam = (employeeTeamId) => {
|
||||
if (employeeTeamId === search.employeeTeamId) return;
|
||||
if (!confirmCloseDirtyTeam()) return;
|
||||
|
||||
setIsTeamFormDirty(false);
|
||||
navigate({
|
||||
search: queryString.stringify({
|
||||
...search,
|
||||
employeeTeamId
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
if (error) return <AlertComponent title={error.message} type="error" />;
|
||||
|
||||
@@ -30,11 +51,16 @@ function ShopTeamsContainer() {
|
||||
.join(" ")}
|
||||
>
|
||||
<div className="shop-teams-layout__list">
|
||||
<ShopEmployeeTeamsListComponent employee_teams={data ? data.employee_teams : []} loading={loading} />
|
||||
<ShopEmployeeTeamsListComponent
|
||||
employee_teams={data ? data.employee_teams : []}
|
||||
loading={loading}
|
||||
onRequestTeamChange={navigateToTeam}
|
||||
selectedTeamId={search.employeeTeamId}
|
||||
/>
|
||||
</div>
|
||||
{hasSelectedTeam ? (
|
||||
<div className="shop-teams-layout__details">
|
||||
<ShopEmployeeTeamsFormComponent />
|
||||
<ShopEmployeeTeamsFormComponent form={form} onDirtyChange={setIsTeamFormDirty} isDirty={isTeamFormDirty} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user