Skip to content
4 changes: 4 additions & 0 deletions frontend/src/api/opsAPI.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export const opsApi = createApi({
budgetLineStatus,
portfolio,
agreementName,
nickName,
agreementType,
projectTitle,
contractNumber,
Expand Down Expand Up @@ -125,6 +126,9 @@ export const opsApi = createApi({
}
});
}
if (nickName) {
nickName.forEach((value) => queryParams.push(`nick_name=${encodeURIComponent(value)}`));
}
if (agreementType) {
agreementType.forEach((type) =>
queryParams.push(`agreement_type=${encodeURIComponent(type.type)}`)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import debounce from "lodash/debounce";
import React from "react";
import { useLocation, useNavigate } from "react-router-dom";
import classnames from "vest/classnames";
import {
useDeleteAgreementMutation,
useGetProjectsQuery,
useGetProductServiceCodesQuery,
useLazyGetAgreementsQuery,
useUpdateAgreementMutation
} from "../../../api/opsAPI";
import { calculateAgreementTotal, cleanAgreementForApi, formatTeamMember } from "../../../helpers/agreement.helpers.js";
Expand Down Expand Up @@ -36,6 +38,11 @@ const AGREEMENT_FILTER_OPTIONS = [
{ label: "Direct Obligation", value: AGREEMENT_TYPES.DIRECT_OBLIGATION }
];

const UNIQUE_ERROR_MESSAGES = {
name: "This title already exists. Try a different one",
nick_name: "This nickname already exists. Try a different one"
};

const useAgreementEditForm = (
isAgreementAwarded,
areAnyBudgetLinesPlanned,
Expand Down Expand Up @@ -89,6 +96,9 @@ const useAgreementEditForm = (

const [updateAgreement] = useUpdateAgreementMutation();
const [deleteAgreement] = useDeleteAgreementMutation();
const [triggerGetAgreements] = useLazyGetAgreementsQuery();

const [uniquenessErrors, setUniquenessErrors] = React.useState({ name: [], nick_name: [] });

const {
agreement,
Expand Down Expand Up @@ -206,13 +216,78 @@ const useAgreementEditForm = (

let res = suite.get();

const runUniqueCheck = React.useCallback(
async (field, value) => {
const trimmed = (value ?? "").trim();
if (!trimmed) {
setUniquenessErrors((prev) => (prev[field].length === 0 ? prev : { ...prev, [field]: [] }));
return false;
}
if (field === "name" && !agreementType) {
return false;
}
try {
const filters =
field === "name"
? { agreementName: [{ name: trimmed }], agreementType: [{ type: agreementType }] }
: { nickName: [trimmed] };
const result = await triggerGetAgreements({ filters, page: 0, limit: 1 }).unwrap();
const totalMatches = result?.count ?? 0;
// The current agreement (in edit mode) is itself in the result set when its
// saved value still matches the input. Treat that one row as not a conflict.
const currentMatchesInput =
!!agreement?.id &&
(field === "name"
? agreement?.agreement_type === agreementType &&
(agreement?.name ?? "").toLowerCase() === trimmed.toLowerCase()
: agreement?.nick_name === trimmed);
const conflict = totalMatches > (currentMatchesInput ? 1 : 0);
setUniquenessErrors((prev) => ({
...prev,
[field]: conflict ? [UNIQUE_ERROR_MESSAGES[field]] : []
}));
return conflict;
} catch {
setUniquenessErrors((prev) => (prev[field].length === 0 ? prev : { ...prev, [field]: [] }));
return false;
}
},
[
agreement?.id,
agreement?.agreement_type,
agreement?.name,
agreement?.nick_name,
agreementType,
triggerGetAgreements
]
);

const checkUniqueOnBlur = React.useMemo(
() => debounce((field, value) => runUniqueCheck(field, value), 300),
[runUniqueCheck]
);

React.useEffect(() => () => checkUniqueOnBlur.cancel(), [checkUniqueOnBlur]);

// When agreement_type changes, re-check the title uniqueness because the
// backend constraint is scoped per type.
React.useEffect(() => {
if (agreementType && agreementTitle) {
checkUniqueOnBlur("name", agreementTitle);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agreementType]);

const hasUniquenessErrors = uniquenessErrors.name.length > 0 || uniquenessErrors.nick_name.length > 0;

const vendorDisabled = agreementReason === "NEW_REQ" || agreementReason === null || agreementReason === "0";
const isAgreementAA = agreementType === AGREEMENT_TYPES.AA;
const shouldDisableBtn =
!agreementTitle ||
!agreement?.project_id ||
!agreementType ||
res.hasErrors() ||
hasUniquenessErrors ||
(isAgreementAA && (!servicingAgency || !requestingAgency));

const cn = classnames(suite.get(), {
Expand Down Expand Up @@ -379,7 +454,19 @@ const useAgreementEditForm = (
wasEditModeRef.current = isEditMode;
}, [isEditMode, setIsCancelling]);

const verifyUniquenessBeforeSubmit = async () => {
checkUniqueOnBlur.cancel();
const [nameConflict, nickNameConflict] = await Promise.all([
runUniqueCheck("name", agreementTitle),
runUniqueCheck("nick_name", agreementNickName)
]);
return !nameConflict && !nickNameConflict;
};

const handleContinue = async () => {
const isUnique = await verifyUniquenessBeforeSubmit();
if (!isUnique) return;

if (shouldRequestChange) {
setShowModal(true);
setModalProps({
Expand Down Expand Up @@ -415,6 +502,9 @@ const useAgreementEditForm = (
};

const handleDraft = async () => {
const isUnique = await verifyUniquenessBeforeSubmit();
if (!isUnique) return;

try {
await saveAgreement();
setHasAgreementChanged(false);
Expand Down Expand Up @@ -559,6 +649,8 @@ const useAgreementEditForm = (
handleCancel,
handleOnChangeSelectedProcurementShop,
runValidate,
checkUniqueOnBlur,
uniquenessErrors,
isProcurementShopDisabled,
disabledMessage,
fundingMethod: FUNDING_METHOD,
Expand Down
Loading