Включить react-hooks/exhaustive-deps и разобрать устаревшие замыкания
Правило было не задано вовсе: плагин eslint-plugin-react-hooks подключает профиль @microsoft/eslint-config-spfx/lib/profiles/react, но правил из него не включает. Теперь в .eslintrc.js явно заданы rules-of-hooks (error) и exhaustive-deps (warn); сборка проходит с нулём предупреждений. Что реально чинилось, а не затыкалось: - shared/useStableCallback.ts — обёртка с неизменной ссылкой над колбэком-пропом (аналог будущего useEffectEvent). Ссылка стабильна, внутри всегда актуальная версия колбэка, поэтому её можно честно класть в зависимости. - onCodeLoaded во всех forms/*/*View.tsx и *Form.tsx идёт в эффект загрузки через эту обёртку: просто дописать проп в зависимости нельзя — заявка перезапрашивалась бы на каждый рендер родителя. - useRequestSubmit: run стабилизирован (useCallback + savingRef вместо чтения состояния), возвращаемый объект мемоизирован. Формы берут из него стабильный setError и указывают в зависимостях его, а не submit целиком. - shared/usePersistedQuery.ts (useRoutedQuery) — было устаревшее замыкание: эффект навсегда запоминал первую версию onQueryChange, а та замкнута на route на момент монтирования, то есть набранный запрос мог уехать в маршрут чужого раздела. Теперь зовётся актуальная версия. - propertyPane/DebugUserField.tsx — то же самое с onResolved. - Формы: в эффект "новая заявка по умолчанию на себя" дописан context (единственный экземпляр на жизнь веб-части, лишних перезапусков не даёт), в ReferralForm ещё и мемоизированный approval. - RequestsTableView: dataKey в зависимости (он и так выводится из view/ statusFilter/debouncedQuery, которые там уже есть). Осознанные отступления оставлены с объяснением ПОЧЕМУ: useMatrixEditor (момент перечитывания матрицы задаёт вызывающий через reloadKey), companiesFieldValue в RecruitingHire/RecruitingSelection. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -290,6 +290,22 @@ module.exports = {
|
|||||||
// Rationale to disable: !!{}
|
// Rationale to disable: !!{}
|
||||||
'no-extra-boolean-cast': 0,
|
'no-extra-boolean-cast': 0,
|
||||||
// ====================================================================
|
// ====================================================================
|
||||||
|
// eslint-plugin-react-hooks (плагин подключён профилем
|
||||||
|
// @microsoft/eslint-config-spfx/lib/profiles/react, но правил он не задаёт)
|
||||||
|
// ====================================================================
|
||||||
|
// RATIONALE: Хуки нельзя звать условно/в циклах — иначе React
|
||||||
|
// перепутает состояние между рендерами.
|
||||||
|
'react-hooks/rules-of-hooks': 2,
|
||||||
|
// RATIONALE: Неполный массив зависимостей useEffect/useCallback/
|
||||||
|
// useMemo — это устаревшее замыкание: эффект продолжает
|
||||||
|
// видеть старые пропсы и state. Держим предупреждением:
|
||||||
|
// там, где отступление осознанное (например, эффект
|
||||||
|
// должен сработать только на смену id, а не на каждое
|
||||||
|
// изменение колбэка), рядом стоит
|
||||||
|
// // eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
// с объяснением причины.
|
||||||
|
'react-hooks/exhaustive-deps': 1,
|
||||||
|
// ====================================================================
|
||||||
// @microsoft/eslint-plugin-spfx
|
// @microsoft/eslint-plugin-spfx
|
||||||
// ====================================================================
|
// ====================================================================
|
||||||
'@microsoft/spfx/no-require-ensure': 2,
|
'@microsoft/spfx/no-require-ensure': 2,
|
||||||
|
|||||||
@@ -477,7 +477,7 @@ export const RequestsTableView: React.FC<IRequestsTableViewProps> = ({
|
|||||||
unsubscribe();
|
unsubscribe();
|
||||||
window.clearInterval(timer);
|
window.clearInterval(timer);
|
||||||
};
|
};
|
||||||
}, [context, types, view, statusFilter, debouncedQuery, page]);
|
}, [context, types, view, statusFilter, debouncedQuery, page, dataKey]);
|
||||||
|
|
||||||
// Все ветки отдают уже нарезанную страницу — просто показываем как есть.
|
// Все ветки отдают уже нарезанную страницу — просто показываем как есть.
|
||||||
const q = debouncedQuery.trim();
|
const q = debouncedQuery.trim();
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
|||||||
import { saveClearanceRequest, clearanceApprovalService } from './submit';
|
import { saveClearanceRequest, clearanceApprovalService } from './submit';
|
||||||
import { loadClearanceRequest } from './load';
|
import { loadClearanceRequest } from './load';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
const ClearanceForm: React.FC<IRequestFormProps> = ({
|
const ClearanceForm: React.FC<IRequestFormProps> = ({
|
||||||
requestType,
|
requestType,
|
||||||
@@ -53,6 +54,14 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
||||||
|
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
|
||||||
|
// каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
|
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
|
||||||
|
// saving/error), а setError стабилен — его и берём.
|
||||||
|
const { setError: setSubmitError } = submit;
|
||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
@@ -78,19 +87,18 @@ const ClearanceForm: React.FC<IRequestFormProps> = ({
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) onCodeLoaded?.(loaded.code);
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||||
}, [editId, editListId]);
|
|
||||||
|
|
||||||
// Новая заявка — дата расторжения по умолчанию сегодняшняя (типичный случай:
|
// Новая заявка — дата расторжения по умолчанию сегодняшняя (типичный случай:
|
||||||
// обходной лист оформляют в день увольнения). При открытии на редактирование
|
// обходной лист оформляют в день увольнения). При открытии на редактирование
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
} from './submit';
|
} from './submit';
|
||||||
import { canEditHrRecordsFields, canEditParkingField } from './approverRights';
|
import { canEditHrRecordsFields, canEditParkingField } from './approverRights';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
interface IClearanceViewProps {
|
interface IClearanceViewProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -49,6 +50,11 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
|||||||
// заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения,
|
// заполняет профильный отдел на своём шаге: КДП — карта СКУД и сумма обучения,
|
||||||
// отдел подбора — парковочная карта (см. approverRights.ts).
|
// отдел подбора — парковочная карта (см. approverRights.ts).
|
||||||
const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
|
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
|
||||||
|
// при каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
@@ -115,7 +121,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
onCodeLoaded?.(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setLockedWriteOffs(
|
setLockedWriteOffs(
|
||||||
@@ -132,7 +138,7 @@ const ClearanceView: React.FC<IClearanceViewProps> = ({ context, itemId, formKey
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [itemId, listId]);
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
// Права на отметки — по должности ТЕКУЩЕГО активного шага пользователя.
|
// Права на отметки — по должности ТЕКУЩЕГО активного шага пользователя.
|
||||||
// isDelegator намеренно не даёт прав: делегировавший не решает за делегата
|
// isDelegator намеренно не даёт прав: делегировавший не решает за делегата
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_MONTHS, IOrganizat
|
|||||||
import { saveDismissalRequest, dismissalApprovalService } from './submit';
|
import { saveDismissalRequest, dismissalApprovalService } from './submit';
|
||||||
import { loadDismissalRequest } from './load';
|
import { loadDismissalRequest } from './load';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
const DismissalForm: React.FC<IRequestFormProps> = ({
|
const DismissalForm: React.FC<IRequestFormProps> = ({
|
||||||
requestType,
|
requestType,
|
||||||
@@ -53,6 +54,14 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
||||||
|
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
|
||||||
|
// каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
|
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
|
||||||
|
// saving/error), а setError стабилен — его и берём.
|
||||||
|
const { setError: setSubmitError } = submit;
|
||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
@@ -82,19 +91,18 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) onCodeLoaded?.(loaded.code);
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||||
}, [editId, editListId]);
|
|
||||||
|
|
||||||
/** Employee/ByEmail → таблица организаций (см. IOrganizationRecord). */
|
/** Employee/ByEmail → таблица организаций (см. IOrganizationRecord). */
|
||||||
const toOrganization = (records: IEmployeeOrganization[]): IOrganizationRecord[] =>
|
const toOrganization = (records: IEmployeeOrganization[]): IOrganizationRecord[] =>
|
||||||
@@ -124,8 +132,9 @@ const DismissalForm: React.FC<IRequestFormProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// context — единственный экземпляр WebPartContext на всё время жизни
|
||||||
}, [editId]);
|
// веб-части, так что перезапуска эффекта эта зависимость не добавляет.
|
||||||
|
}, [editId, context]);
|
||||||
|
|
||||||
// Выбор другого сотрудника подтягивает его таблицу организаций.
|
// Выбор другого сотрудника подтягивает его таблицу организаций.
|
||||||
const handleEmployeeChange = (login: string): void => {
|
const handleEmployeeChange = (login: string): void => {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { dismissalApprovalService, updateAssetsAcceptor } from './submit';
|
|||||||
import { loadClearanceRequest } from '../Clearance/load';
|
import { loadClearanceRequest } from '../Clearance/load';
|
||||||
import { ensureClearanceForDismissal } from './createClearance';
|
import { ensureClearanceForDismissal } from './createClearance';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Статусы обходного листа, при которых его СОГЛАСОВАНИЕ уже пройдено целиком
|
* Статусы обходного листа, при которых его СОГЛАСОВАНИЕ уже пройдено целиком
|
||||||
@@ -56,6 +57,11 @@ interface IDismissalViewProps {
|
|||||||
// Переиспользует ту же секцию, что и форма подачи; редактирование выключено через
|
// Переиспользует ту же секцию, что и форма подачи; редактирование выключено через
|
||||||
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
||||||
const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
|
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
|
||||||
|
// при каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
@@ -94,7 +100,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
onCodeLoaded?.(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -108,7 +114,7 @@ const DismissalView: React.FC<IDismissalViewProps> = ({ context, itemId, formKey
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [itemId, listId]);
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
// Печать заявления запрещена до согласования ПАРНОГО обходного листа: сначала
|
// Печать заявления запрещена до согласования ПАРНОГО обходного листа: сначала
|
||||||
// должны отработать/подтвердиться все связанные с увольнением отметки (карты,
|
// должны отработать/подтвердиться все связанные с увольнением отметки (карты,
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import { CandidateSection } from './sections/CandidateSection';
|
|||||||
import { PpeSection } from './sections/PpeSection';
|
import { PpeSection } from './sections/PpeSection';
|
||||||
import { ConditionsSection } from './sections/ConditionsSection';
|
import { ConditionsSection } from './sections/ConditionsSection';
|
||||||
import { AgencySection } from './sections/AgencySection';
|
import { AgencySection } from './sections/AgencySection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
||||||
requestType,
|
requestType,
|
||||||
@@ -83,6 +84,14 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
||||||
|
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
|
||||||
|
// каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
|
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
|
||||||
|
// saving/error), а setError стабилен — его и берём.
|
||||||
|
const { setError: setSubmitError } = submit;
|
||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
|
|
||||||
@@ -101,19 +110,19 @@ const RecruitingHireForm: React.FC<IRequestFormProps> = ({
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) onCodeLoaded?.(loaded.code);
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
setExistingAttachments(atts);
|
setExistingAttachments(atts);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [editId, editListId]);
|
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||||
|
|
||||||
// Маршрут согласования (планируемый) резолвится по email инициатора — текущего
|
// Маршрут согласования (планируемый) резолвится по email инициатора — текущего
|
||||||
// пользователя (кандидата ещё нет в системе). У заявки, открытой на
|
// пользователя (кандидата ещё нет в системе). У заявки, открытой на
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { CandidateSection } from './sections/CandidateSection';
|
|||||||
import { PpeSection } from './sections/PpeSection';
|
import { PpeSection } from './sections/PpeSection';
|
||||||
import { ConditionsSection } from './sections/ConditionsSection';
|
import { ConditionsSection } from './sections/ConditionsSection';
|
||||||
import { AgencySection } from './sections/AgencySection';
|
import { AgencySection } from './sections/AgencySection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
interface IRecruitingHireViewProps {
|
interface IRecruitingHireViewProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -56,6 +57,11 @@ const NOOP_SET: SetField = () => undefined;
|
|||||||
// Переиспользует те же секции, что и форма подачи; редактирование выключено
|
// Переиспользует те же секции, что и форма подачи; редактирование выключено
|
||||||
// через <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
// через <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
||||||
const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
|
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
|
||||||
|
// при каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
@@ -116,7 +122,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
onCodeLoaded?.(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setAttachments(files);
|
setAttachments(files);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -131,7 +137,7 @@ const RecruitingHireView: React.FC<IRecruitingHireViewProps> = ({ context, itemI
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [itemId, listId]);
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import { DepartmentSection } from './sections/DepartmentSection';
|
|||||||
import { VacancySection } from './sections/VacancySection';
|
import { VacancySection } from './sections/VacancySection';
|
||||||
import { HealthTripsSection } from './sections/HealthTripsSection';
|
import { HealthTripsSection } from './sections/HealthTripsSection';
|
||||||
import { ConditionsSection } from './sections/ConditionsSection';
|
import { ConditionsSection } from './sections/ConditionsSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
||||||
requestType,
|
requestType,
|
||||||
@@ -69,6 +70,14 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
||||||
|
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
|
||||||
|
// каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
|
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
|
||||||
|
// saving/error), а setError стабилен — его и берём.
|
||||||
|
const { setError: setSubmitError } = submit;
|
||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
@@ -86,20 +95,19 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) onCodeLoaded?.(loaded.code);
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
setExistingAttachments(atts);
|
setExistingAttachments(atts);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||||
}, [editId, editListId]);
|
|
||||||
|
|
||||||
// Компании ответственного (Employee/ByEmail.company/companyId) → предзаполнение
|
// Компании ответственного (Employee/ByEmail.company/companyId) → предзаполнение
|
||||||
// «1.6 Организация» (Companies/CompanyPickerMulti). companyId из Employee/ByEmail
|
// «1.6 Организация» (Companies/CompanyPickerMulti). companyId из Employee/ByEmail
|
||||||
@@ -132,8 +140,9 @@ const RecruitingSelectionForm: React.FC<IRequestFormProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// context — единственный экземпляр WebPartContext на всё время жизни
|
||||||
}, [editId]);
|
// веб-части, так что перезапуска эффекта эта зависимость не добавляет.
|
||||||
|
}, [editId, context]);
|
||||||
|
|
||||||
// Выбор другого ответственного подтягивает его подразделение и организацию.
|
// Выбор другого ответственного подтягивает его подразделение и организацию.
|
||||||
const handleResponsibleChange = (login: string): void => {
|
const handleResponsibleChange = (login: string): void => {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { DepartmentSection } from './sections/DepartmentSection';
|
|||||||
import { VacancySection } from './sections/VacancySection';
|
import { VacancySection } from './sections/VacancySection';
|
||||||
import { HealthTripsSection } from './sections/HealthTripsSection';
|
import { HealthTripsSection } from './sections/HealthTripsSection';
|
||||||
import { ConditionsSection } from './sections/ConditionsSection';
|
import { ConditionsSection } from './sections/ConditionsSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
interface IRecruitingSelectionViewProps {
|
interface IRecruitingSelectionViewProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -47,6 +48,11 @@ const NOOP_SET: SetField = () => undefined;
|
|||||||
// Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же
|
// Заявка «на подбор» только для просмотра (режим согласующего). Переиспользует те же
|
||||||
// секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>.
|
// секции, что и форма подачи; редактирование выключено через <FormModeProvider readOnly>.
|
||||||
const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
|
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
|
||||||
|
// при каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
@@ -80,7 +86,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
onCodeLoaded?.(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setAttachments(files);
|
setAttachments(files);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -95,7 +101,7 @@ const RecruitingSelectionView: React.FC<IRecruitingSelectionViewProps> = ({ cont
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [itemId, listId]);
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { CandidateSection } from './sections/CandidateSection';
|
|||||||
import { VacancySection } from './sections/VacancySection';
|
import { VacancySection } from './sections/VacancySection';
|
||||||
import { CandidateInfoSection } from './sections/CandidateInfoSection';
|
import { CandidateInfoSection } from './sections/CandidateInfoSection';
|
||||||
import { AssessmentSection } from './sections/AssessmentSection';
|
import { AssessmentSection } from './sections/AssessmentSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
const ReferralForm: React.FC<IRequestFormProps> = ({
|
const ReferralForm: React.FC<IRequestFormProps> = ({
|
||||||
requestType,
|
requestType,
|
||||||
@@ -58,6 +59,14 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
||||||
|
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
|
||||||
|
// каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
|
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
|
||||||
|
// saving/error), а setError стабилен — его и берём.
|
||||||
|
const { setError: setSubmitError } = submit;
|
||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
@@ -83,19 +92,18 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) onCodeLoaded?.(loaded.code);
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||||
}, [editId, editListId]);
|
|
||||||
|
|
||||||
// Новая заявка: рекомендатель — сам подающий (подставляем его; подразделение —
|
// Новая заявка: рекомендатель — сам подающий (подставляем его; подразделение —
|
||||||
// из профиля SharePoint SPS-Department, как в «Обходном листе»; должность и
|
// из профиля SharePoint SPS-Department, как в «Обходном листе»; должность и
|
||||||
@@ -136,8 +144,10 @@ const ReferralForm: React.FC<IRequestFormProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// context — единственный экземпляр WebPartContext на всё время жизни
|
||||||
}, [editId]);
|
// веб-части, approval мемоизирован по editListId: перезапуска эффекта эти
|
||||||
|
// зависимости не добавляют.
|
||||||
|
}, [editId, context, approval]);
|
||||||
|
|
||||||
// Выбор другого рекомендателя подтягивает его подразделение из профиля,
|
// Выбор другого рекомендателя подтягивает его подразделение из профиля,
|
||||||
// должность и организации — из Employee/ByEmail. Оба поля (Position/
|
// должность и организации — из Employee/ByEmail. Оба поля (Position/
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { CandidateSection } from './sections/CandidateSection';
|
|||||||
import { VacancySection } from './sections/VacancySection';
|
import { VacancySection } from './sections/VacancySection';
|
||||||
import { CandidateInfoSection } from './sections/CandidateInfoSection';
|
import { CandidateInfoSection } from './sections/CandidateInfoSection';
|
||||||
import { AssessmentSection } from './sections/AssessmentSection';
|
import { AssessmentSection } from './sections/AssessmentSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
interface IReferralViewProps {
|
interface IReferralViewProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -48,6 +49,11 @@ const NOOP_SET: SetField = () => undefined;
|
|||||||
// те же секции, что и форма подачи; редактирование выключено через
|
// те же секции, что и форма подачи; редактирование выключено через
|
||||||
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
||||||
const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
|
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
|
||||||
|
// при каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
@@ -101,7 +107,7 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
onCodeLoaded?.(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -115,7 +121,7 @@ const ReferralView: React.FC<IReferralViewProps> = ({ context, itemId, formKey,
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [itemId, listId]);
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { getApplicantProfile } from './services/profile.service';
|
|||||||
import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME } from './config';
|
import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME } from './config';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
import { ContactSection } from './sections/ContactSection';
|
import { ContactSection } from './sections/ContactSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
/** Текст карточки Planner из данных заявки (соответствует старому buildPlannerNotes). */
|
/** Текст карточки Planner из данных заявки (соответствует старому buildPlannerNotes). */
|
||||||
function buildPlannerNotes(data: IFormData, code: string): string {
|
function buildPlannerNotes(data: IFormData, code: string): string {
|
||||||
@@ -86,6 +87,14 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
||||||
|
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
|
||||||
|
// каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
|
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
|
||||||
|
// saving/error), а setError стабилен — его и берём.
|
||||||
|
const { setError: setSubmitError } = submit;
|
||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
@@ -102,20 +111,19 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) onCodeLoaded?.(loaded.code);
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
setExistingAttachments(atts);
|
setExistingAttachments(atts);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||||
}, [editId, editListId]);
|
|
||||||
|
|
||||||
// Предзаполнение контактов заявителя текущим пользователем — только для новой заявки.
|
// Предзаполнение контактов заявителя текущим пользователем — только для новой заявки.
|
||||||
// Телефон/отдел/должность в pageContext нет — берём из Graph (/me), с фолбэком.
|
// Телефон/отдел/должность в pageContext нет — берём из Graph (/me), с фолбэком.
|
||||||
@@ -140,8 +148,9 @@ const Ticket1CForm: React.FC<IRequestFormProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// context — единственный экземпляр WebPartContext на всё время жизни
|
||||||
}, [editId]);
|
// веб-части, так что перезапуска эффекта эта зависимость не добавляет.
|
||||||
|
}, [editId, context]);
|
||||||
|
|
||||||
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
|
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
|
||||||
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
|
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { PlannerTicketService } from './services/plannerTicket.service';
|
|||||||
import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME, PLANNER_REJECTED_BUCKET_NAME } from './config';
|
import { PLANNER_PLAN_ID, PLANNER_BUCKET_NAME, PLANNER_REJECTED_BUCKET_NAME } from './config';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
import { ContactSection } from './sections/ContactSection';
|
import { ContactSection } from './sections/ContactSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
interface ITicket1CViewProps {
|
interface ITicket1CViewProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -83,6 +84,11 @@ function buildPlannerNotes(
|
|||||||
|
|
||||||
// Заявка в отдел 1С только для просмотра (режим согласующего).
|
// Заявка в отдел 1С только для просмотра (режим согласующего).
|
||||||
const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
|
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
|
||||||
|
// при каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
@@ -118,7 +124,7 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
onCodeLoaded?.(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setAttachments(files);
|
setAttachments(files);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -133,7 +139,7 @@ const Ticket1CView: React.FC<ITicket1CViewProps> = ({ context, itemId, formKey,
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [itemId, listId]);
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
// Передать согласованную/отклонённую заявку в Planner. PlannerTaskId — защита
|
// Передать согласованную/отклонённую заявку в Planner. PlannerTaskId — защита
|
||||||
// от повторного создания карточки, если решение применяется из нескольких
|
// от повторного создания карточки, если решение применяется из нескольких
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { saveTmcTransferRequest, tmcTransferApprovalService } from './submit';
|
|||||||
import { loadTmcTransferRequest } from './load';
|
import { loadTmcTransferRequest } from './load';
|
||||||
import { HeaderSection } from './sections/HeaderSection';
|
import { HeaderSection } from './sections/HeaderSection';
|
||||||
import { ItemsSection } from './sections/ItemsSection';
|
import { ItemsSection } from './sections/ItemsSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
const TmcTransferForm: React.FC<IRequestFormProps> = ({
|
const TmcTransferForm: React.FC<IRequestFormProps> = ({
|
||||||
requestType,
|
requestType,
|
||||||
@@ -67,6 +68,14 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
||||||
|
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
|
||||||
|
// каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
|
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
|
||||||
|
// saving/error), а setError стабилен — его и берём.
|
||||||
|
const { setError: setSubmitError } = submit;
|
||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
|
|
||||||
@@ -135,19 +144,19 @@ const TmcTransferForm: React.FC<IRequestFormProps> = ({
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) onCodeLoaded?.(loaded.code);
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
setExistingAttachments(atts);
|
setExistingAttachments(atts);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [editId, editListId]);
|
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||||
|
|
||||||
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
|
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
|
||||||
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
|
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { loadTmcTransferRequest } from './load';
|
|||||||
import { tmcTransferApprovalService } from './submit';
|
import { tmcTransferApprovalService } from './submit';
|
||||||
import { HeaderSection } from './sections/HeaderSection';
|
import { HeaderSection } from './sections/HeaderSection';
|
||||||
import { ItemsSection } from './sections/ItemsSection';
|
import { ItemsSection } from './sections/ItemsSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
interface ITmcTransferViewProps {
|
interface ITmcTransferViewProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -54,6 +55,11 @@ interface ITmcTransferViewProps {
|
|||||||
|
|
||||||
// Заявка «СЗ на перемещение ТМЦ» только для просмотра (режим согласующего).
|
// Заявка «СЗ на перемещение ТМЦ» только для просмотра (режим согласующего).
|
||||||
const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
|
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
|
||||||
|
// при каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
@@ -107,7 +113,7 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
onCodeLoaded?.(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setAttachments(files);
|
setAttachments(files);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -122,7 +128,7 @@ const TmcTransferView: React.FC<ITmcTransferViewProps> = ({ context, itemId, for
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [itemId, listId]);
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_DAYS } from './mod
|
|||||||
import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit';
|
import { saveUnpaidLeaveRequest, unpaidLeaveApprovalService } from './submit';
|
||||||
import { loadUnpaidLeaveRequest } from './load';
|
import { loadUnpaidLeaveRequest } from './load';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
||||||
requestType,
|
requestType,
|
||||||
@@ -53,6 +54,14 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
||||||
|
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
|
||||||
|
// каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
|
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
|
||||||
|
// saving/error), а setError стабилен — его и берём.
|
||||||
|
const { setError: setSubmitError } = submit;
|
||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
@@ -78,19 +87,18 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) onCodeLoaded?.(loaded.code);
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||||
}, [editId, editListId]);
|
|
||||||
|
|
||||||
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя,
|
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя,
|
||||||
// его должность и организации (Employee/ByEmail — см. shared/dictionary/
|
// его должность и организации (Employee/ByEmail — см. shared/dictionary/
|
||||||
@@ -116,8 +124,9 @@ const UnpaidLeaveForm: React.FC<IRequestFormProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// context — единственный экземпляр WebPartContext на всё время жизни
|
||||||
}, [editId]);
|
// веб-части, так что перезапуска эффекта эта зависимость не добавляет.
|
||||||
|
}, [editId, context]);
|
||||||
|
|
||||||
// Выбор другого сотрудника подтягивает его должность и организации.
|
// Выбор другого сотрудника подтягивает его должность и организации.
|
||||||
const handleEmployeeChange = (login: string): void => {
|
const handleEmployeeChange = (login: string): void => {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
|||||||
import { loadUnpaidLeaveRequest } from './load';
|
import { loadUnpaidLeaveRequest } from './load';
|
||||||
import { unpaidLeaveApprovalService } from './submit';
|
import { unpaidLeaveApprovalService } from './submit';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
interface IUnpaidLeaveViewProps {
|
interface IUnpaidLeaveViewProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -42,6 +43,11 @@ const NOOP_SET: SetField = () => undefined;
|
|||||||
// ту же секцию, что и форма подачи; редактирование выключено через
|
// ту же секцию, что и форма подачи; редактирование выключено через
|
||||||
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
||||||
const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
|
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
|
||||||
|
// при каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
@@ -77,7 +83,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
onCodeLoaded?.(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -91,7 +97,7 @@ const UnpaidLeaveView: React.FC<IUnpaidLeaveViewProps> = ({ context, itemId, for
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [itemId, listId]);
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField, MIN_NOTICE_DAYS } from './mod
|
|||||||
import { saveVacationRequest, vacationApprovalService } from './submit';
|
import { saveVacationRequest, vacationApprovalService } from './submit';
|
||||||
import { loadVacationRequest } from './load';
|
import { loadVacationRequest } from './load';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
const VacationForm: React.FC<IRequestFormProps> = ({
|
const VacationForm: React.FC<IRequestFormProps> = ({
|
||||||
requestType,
|
requestType,
|
||||||
@@ -53,6 +54,14 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
||||||
|
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
|
||||||
|
// каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
|
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
|
||||||
|
// saving/error), а setError стабилен — его и берём.
|
||||||
|
const { setError: setSubmitError } = submit;
|
||||||
|
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
const set: SetField = (key, value) => setData((prev) => ({ ...prev, [key]: value }));
|
||||||
@@ -78,19 +87,18 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) onCodeLoaded?.(loaded.code);
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||||
}, [editId, editListId]);
|
|
||||||
|
|
||||||
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя,
|
// Новая заявка — по умолчанию на себя: подставляем текущего пользователя,
|
||||||
// его должность и организации (Employee/ByEmail — см. shared/dictionary/
|
// его должность и организации (Employee/ByEmail — см. shared/dictionary/
|
||||||
@@ -116,8 +124,9 @@ const VacationForm: React.FC<IRequestFormProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// context — единственный экземпляр WebPartContext на всё время жизни
|
||||||
}, [editId]);
|
// веб-части, так что перезапуска эффекта эта зависимость не добавляет.
|
||||||
|
}, [editId, context]);
|
||||||
|
|
||||||
// Выбор другого сотрудника подтягивает его должность и организации (как в
|
// Выбор другого сотрудника подтягивает его должность и организации (как в
|
||||||
// исходной pto-tracker, но источник — Employee/ByEmail вместо SPS-JobTitle).
|
// исходной pto-tracker, но источник — Employee/ByEmail вместо SPS-JobTitle).
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { FORM_ID, IFormData, EMPTY_DATA, SetField } from './model';
|
|||||||
import { loadVacationRequest } from './load';
|
import { loadVacationRequest } from './load';
|
||||||
import { vacationApprovalService } from './submit';
|
import { vacationApprovalService } from './submit';
|
||||||
import { RequestSection } from './sections/RequestSection';
|
import { RequestSection } from './sections/RequestSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
interface IVacationViewProps {
|
interface IVacationViewProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -42,6 +43,11 @@ const NOOP_SET: SetField = () => undefined;
|
|||||||
// же секцию, что и форма подачи; редактирование выключено через
|
// же секцию, что и форма подачи; редактирование выключено через
|
||||||
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
// <FormModeProvider readOnly> — поля сами становятся неизменяемыми.
|
||||||
const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
|
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
|
||||||
|
// при каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
@@ -77,7 +83,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
onCodeLoaded?.(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -91,7 +97,7 @@ const VacationView: React.FC<IVacationViewProps> = ({ context, itemId, formKey,
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [itemId, listId]);
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className={styles.state}>Загрузка заявки…</div>;
|
return <div className={styles.state}>Загрузка заявки…</div>;
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { loadPrefillFromHire } from './prefill';
|
|||||||
import { loadWorkplaceRequest } from './load';
|
import { loadWorkplaceRequest } from './load';
|
||||||
import { EmployeeSection } from './sections/EmployeeSection';
|
import { EmployeeSection } from './sections/EmployeeSection';
|
||||||
import { EquipmentSection } from './sections/EquipmentSection';
|
import { EquipmentSection } from './sections/EquipmentSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
||||||
requestType,
|
requestType,
|
||||||
@@ -71,6 +72,14 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
const [requestStatus, setRequestStatus] = useState<string | undefined>(undefined);
|
||||||
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
const [requestCode, setRequestCode] = useState<string | undefined>(undefined);
|
||||||
const submit = useRequestSubmit();
|
const submit = useRequestSubmit();
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → EditFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффектов идёт
|
||||||
|
// стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы при
|
||||||
|
// каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
|
// submit целиком в зависимости класть нельзя (пересоздаётся при каждой смене
|
||||||
|
// saving/error), а setError стабилен — его и берём.
|
||||||
|
const { setError: setSubmitError } = submit;
|
||||||
|
|
||||||
// Редактировать можно новую/черновик/возвращённую/отклонённую; иначе только чтение.
|
// Редактировать можно новую/черновик/возвращённую/отклонённую; иначе только чтение.
|
||||||
const readOnly = !isEditableStatus(requestStatus);
|
const readOnly = !isEditableStatus(requestStatus);
|
||||||
@@ -91,19 +100,19 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setRequestStatus(loaded.status);
|
setRequestStatus(loaded.status);
|
||||||
setRequestCode(loaded.code || undefined);
|
setRequestCode(loaded.code || undefined);
|
||||||
if (loaded.code) onCodeLoaded?.(loaded.code);
|
if (loaded.code) notifyCode(loaded.code);
|
||||||
setExistingAttachments(atts);
|
setExistingAttachments(atts);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
submit.setError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
setSubmitError(`Не удалось загрузить заявку №${editId}: ${friendlyErrorMessage(e)}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [editId, editListId]);
|
}, [editId, editListId, notifyCode, setSubmitError]);
|
||||||
|
|
||||||
// Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource).
|
// Предзаполнение из «Заявки на прием», если форма открыта «на основе» (rpsource).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -122,13 +131,13 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (alive) {
|
if (alive) {
|
||||||
submit.setError(`Не удалось предзаполнить из заявки на прием №${sourceId}: ${(e as Error).message}`);
|
setSubmitError(`Не удалось предзаполнить из заявки на прием №${sourceId}: ${(e as Error).message}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [sourceId]);
|
}, [sourceId, setSubmitError]);
|
||||||
|
|
||||||
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
|
// editListId уже проверен в EditFeature (см. её комментарий) — если бы партиции
|
||||||
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
|
// не нашлось в реестре, форма сюда вообще не смонтировалась бы, поэтому здесь
|
||||||
@@ -156,8 +165,9 @@ const WorkplaceSetupForm: React.FC<IRequestFormProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// context — единственный экземпляр WebPartContext на всё время жизни
|
||||||
}, [editId, userEmail]);
|
// веб-части, так что перезапуска эффекта эта зависимость не добавляет.
|
||||||
|
}, [editId, userEmail, context]);
|
||||||
|
|
||||||
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
|
// pageUrl формы (requestType.id совпадает с ним по конвенции портала, см.
|
||||||
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
|
// data/requests.ts) → числовой RequestTypeId, нужный approval-route API.
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { loadWorkplaceRequest } from './load';
|
|||||||
import { workplaceApprovalService, updateReviewFields } from './submit';
|
import { workplaceApprovalService, updateReviewFields } from './submit';
|
||||||
import { EmployeeSection } from './sections/EmployeeSection';
|
import { EmployeeSection } from './sections/EmployeeSection';
|
||||||
import { EquipmentSection } from './sections/EquipmentSection';
|
import { EquipmentSection } from './sections/EquipmentSection';
|
||||||
|
import { useStableCallback } from '../../shared/useStableCallback';
|
||||||
|
|
||||||
interface IWorkplaceSetupViewProps {
|
interface IWorkplaceSetupViewProps {
|
||||||
context: WebPartContext;
|
context: WebPartContext;
|
||||||
@@ -58,6 +59,11 @@ type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
|||||||
// Исключение: первый согласующий шаг (по маршруту — непосредственный руководитель)
|
// Исключение: первый согласующий шаг (по маршруту — непосредственный руководитель)
|
||||||
// на своём этапе может заполнить «Необходимое ПО» и «Доступы» (инициатор их не знает).
|
// на своём этапе может заполнить «Необходимое ПО» и «Доступы» (инициатор их не знает).
|
||||||
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemId, formKey, listId, onCodeLoaded }) => {
|
||||||
|
// onCodeLoaded — колбэк родителя (RequestPortal → ApproveFeature): его ссылка
|
||||||
|
// может меняться на каждом рендере, поэтому в зависимости эффекта загрузки
|
||||||
|
// идёт стабильная обёртка, а не сам проп — иначе заявка перезапрашивалась бы
|
||||||
|
// при каждом рендере родителя.
|
||||||
|
const notifyCode = useStableCallback(onCodeLoaded);
|
||||||
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
const [data, setData] = useState<IFormData>(EMPTY_DATA);
|
||||||
const [status, setStatus] = useState<string>('');
|
const [status, setStatus] = useState<string>('');
|
||||||
const [code, setCode] = useState<string>('');
|
const [code, setCode] = useState<string>('');
|
||||||
@@ -122,7 +128,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
setData(loaded.data);
|
setData(loaded.data);
|
||||||
setStatus(loaded.status);
|
setStatus(loaded.status);
|
||||||
setCode(loaded.code);
|
setCode(loaded.code);
|
||||||
onCodeLoaded?.(loaded.code);
|
notifyCode(loaded.code);
|
||||||
setAttachments(files);
|
setAttachments(files);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -137,7 +143,7 @@ const WorkplaceSetupView: React.FC<IWorkplaceSetupViewProps> = ({ context, itemI
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [itemId, listId]);
|
}, [itemId, listId, notifyCode]);
|
||||||
|
|
||||||
// Правка «ПО»/«Доступы» доступна, когда сейчас очередь ПЕРВОГО шага согласования
|
// Правка «ПО»/«Доступы» доступна, когда сейчас очередь ПЕРВОГО шага согласования
|
||||||
// (по маршруту — непосредственного руководителя) и это текущий пользователь.
|
// (по маршруту — непосредственного руководителя) и это текущий пользователь.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||||
import { resolveDebugUser } from '../shared/impersonation';
|
import { resolveDebugUser } from '../shared/impersonation';
|
||||||
|
import { useStableCallback } from '../shared/useStableCallback';
|
||||||
|
|
||||||
export interface IDebugUserFieldProps {
|
export interface IDebugUserFieldProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -22,6 +23,10 @@ const DEBOUNCE_MS = 400;
|
|||||||
* чтобы не гадать, кого именно подставили.
|
* чтобы не гадать, кого именно подставили.
|
||||||
*/
|
*/
|
||||||
export const DebugUserField: React.FC<IDebugUserFieldProps> = ({ label, description, context, initialQuery, onResolved }) => {
|
export const DebugUserField: React.FC<IDebugUserFieldProps> = ({ label, description, context, initialQuery, onResolved }) => {
|
||||||
|
// onResolved приходит из панели свойств и пересоздаётся на каждом её рендере:
|
||||||
|
// в зависимостях эффекта он перезапускал бы поиск, без него — эффект звал бы
|
||||||
|
// навсегда запомненную первую версию колбэка. Стабильная обёртка снимает оба.
|
||||||
|
const notifyResolved = useStableCallback(onResolved);
|
||||||
const [query, setQuery] = React.useState(initialQuery || '');
|
const [query, setQuery] = React.useState(initialQuery || '');
|
||||||
const [status, setStatus] = React.useState<Status>('idle');
|
const [status, setStatus] = React.useState<Status>('idle');
|
||||||
const [found, setFound] = React.useState<{ email: string; displayName: string } | undefined>(undefined);
|
const [found, setFound] = React.useState<{ email: string; displayName: string } | undefined>(undefined);
|
||||||
@@ -32,7 +37,7 @@ export const DebugUserField: React.FC<IDebugUserFieldProps> = ({ label, descript
|
|||||||
if (!text) {
|
if (!text) {
|
||||||
setStatus('idle');
|
setStatus('idle');
|
||||||
setFound(undefined);
|
setFound(undefined);
|
||||||
onResolved('', '', '');
|
notifyResolved('', '', '');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,25 +54,25 @@ export const DebugUserField: React.FC<IDebugUserFieldProps> = ({ label, descript
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (isEmail) {
|
if (isEmail) {
|
||||||
setFound(hit);
|
setFound(hit);
|
||||||
onResolved(text, text, hit ? hit.displayName : '');
|
notifyResolved(text, text, hit ? hit.displayName : '');
|
||||||
} else if (hit) {
|
} else if (hit) {
|
||||||
setStatus('found');
|
setStatus('found');
|
||||||
setFound(hit);
|
setFound(hit);
|
||||||
onResolved(text, hit.email, hit.displayName);
|
notifyResolved(text, hit.email, hit.displayName);
|
||||||
} else {
|
} else {
|
||||||
setStatus('notfound');
|
setStatus('notfound');
|
||||||
setFound(undefined);
|
setFound(undefined);
|
||||||
onResolved(text, '', '');
|
notifyResolved(text, '', '');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (isEmail) {
|
if (isEmail) {
|
||||||
onResolved(text, text, '');
|
notifyResolved(text, text, '');
|
||||||
} else {
|
} else {
|
||||||
setStatus('notfound');
|
setStatus('notfound');
|
||||||
setFound(undefined);
|
setFound(undefined);
|
||||||
onResolved(text, '', '');
|
notifyResolved(text, '', '');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, DEBOUNCE_MS);
|
}, DEBOUNCE_MS);
|
||||||
@@ -76,8 +81,7 @@ export const DebugUserField: React.FC<IDebugUserFieldProps> = ({ label, descript
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [query, context, notifyResolved]);
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -181,7 +181,12 @@ export function useMatrixEditor(
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
// load — стабильная ссылка от вызывающего (useCallback); перезагрузка по reloadKey/ready/retrySeq.
|
// load в зависимостях СОЗНАТЕЛЬНО нет: когда матрицу перечитывать —
|
||||||
|
// решает вызывающий через reloadKey (у него это, например, «выбранный
|
||||||
|
// сотрудник + id заявки»), а сам load пересоздаётся и от тех данных,
|
||||||
|
// которые перезагрузки не требуют. С load в массиве маршрут дёргался бы
|
||||||
|
// на каждое такое изменение. Устаревшего замыкания при этом нет: любой
|
||||||
|
// повторный запуск (reloadKey/ready/retrySeq) берёт свежий load.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [reloadKey, ready, retrySeq]);
|
}, [reloadKey, ready, retrySeq]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
// Общая машина сохранения заявки для ВСЕХ форм портала.
|
// Общая машина сохранения заявки для ВСЕХ форм портала.
|
||||||
// Отвечает ТОЛЬКО за транзиентное состояние операции (идёт сохранение / ошибка /
|
// Отвечает ТОЛЬКО за транзиентное состояние операции (идёт сохранение / ошибка /
|
||||||
@@ -15,6 +15,7 @@ export interface IRequestSubmit {
|
|||||||
/**
|
/**
|
||||||
* Запустить сохранение. validate (опц.) возвращает текст ошибки или undefined.
|
* Запустить сохранение. validate (опц.) возвращает текст ошибки или undefined.
|
||||||
* Повторный вызов во время сохранения игнорируется (защита от дабл-клика).
|
* Повторный вызов во время сохранения игнорируется (защита от дабл-клика).
|
||||||
|
* Ссылка на функцию неизменна — её можно класть в зависимости хуков.
|
||||||
*/
|
*/
|
||||||
run: (
|
run: (
|
||||||
kind: SubmitKind,
|
kind: SubmitKind,
|
||||||
@@ -29,13 +30,17 @@ export function useRequestSubmit(): IRequestSubmit {
|
|||||||
const [kind, setKind] = useState<SubmitKind | undefined>(undefined);
|
const [kind, setKind] = useState<SubmitKind | undefined>(undefined);
|
||||||
const [savedKind, setSavedKind] = useState<SubmitKind | undefined>(undefined);
|
const [savedKind, setSavedKind] = useState<SubmitKind | undefined>(undefined);
|
||||||
const [error, setError] = useState<string | undefined>(undefined);
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
|
// Тот же признак «идёт сохранение», но читаемый синхронно. Нужен, чтобы run
|
||||||
|
// не зависела от состояния и сохраняла неизменную ссылку: формы кладут её
|
||||||
|
// (и весь submit) в зависимости эффектов.
|
||||||
|
const savingRef = useRef<boolean>(false);
|
||||||
|
|
||||||
const run = async (
|
const run = useCallback(async (
|
||||||
k: SubmitKind,
|
k: SubmitKind,
|
||||||
save: () => Promise<void>,
|
save: () => Promise<void>,
|
||||||
validate?: () => string | undefined
|
validate?: () => string | undefined
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (saving) {
|
if (savingRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setKind(k);
|
setKind(k);
|
||||||
@@ -45,6 +50,7 @@ export function useRequestSubmit(): IRequestSubmit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
|
savingRef.current = true;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await save();
|
await save();
|
||||||
@@ -52,9 +58,20 @@ export function useRequestSubmit(): IRequestSubmit {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(`Ошибка сохранения: ${(e as Error).message}`);
|
setError(`Ошибка сохранения: ${(e as Error).message}`);
|
||||||
} finally {
|
} finally {
|
||||||
|
// require-atomic-updates ловит присваивание после await, но здесь это не
|
||||||
|
// гонка: savingRef — не производное состояние, а флаг «занято» с одним
|
||||||
|
// владельцем, и второй вызов run до этой строки просто не проходит.
|
||||||
|
// eslint-disable-next-line require-atomic-updates
|
||||||
|
savingRef.current = false;
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return { saving, kind, savedKind, error, run, setError };
|
// Объект пересоздаётся только при РЕАЛЬНОЙ смене состояния операции, а не на
|
||||||
|
// каждом рендере формы: run и setError стабильны, поэтому эффекты, которым
|
||||||
|
// нужен только setError, не перезапускаются вовсе.
|
||||||
|
return useMemo(
|
||||||
|
() => ({ saving, kind, savedKind, error, run, setError }),
|
||||||
|
[saving, kind, savedKind, error, run]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { useStableCallback } from './useStableCallback';
|
||||||
|
|
||||||
// Задержка перед тем, как введённый текст уйдёт в запрос. Без неё поиск
|
// Задержка перед тем, как введённый текст уйдёт в запрос. Без неё поиск
|
||||||
// срабатывает на КАЖДОЕ нажатие клавиши: «согласование» — это 12 полных циклов
|
// срабатывает на КАЖДОЕ нажатие клавиши: «согласование» — это 12 полных циклов
|
||||||
@@ -57,15 +58,17 @@ export function useRoutedQuery(
|
|||||||
const [input, setInput] = React.useState<string>(routeQuery);
|
const [input, setInput] = React.useState<string>(routeQuery);
|
||||||
const debounced = useDebouncedQuery(input);
|
const debounced = useDebouncedQuery(input);
|
||||||
const pushed = React.useRef<string>(routeQuery);
|
const pushed = React.useRef<string>(routeQuery);
|
||||||
|
// onQueryChange родитель пересоздаёт на каждом рендере: в зависимостях он
|
||||||
|
// гонял бы эффект вхолостую, а без него эффект навсегда запомнил бы ПЕРВУЮ
|
||||||
|
// версию колбэка (а она замкнута на раздел, открытый при монтировании, — и
|
||||||
|
// запрос уезжал бы не в тот маршрут). Стабильная обёртка снимает и то и другое.
|
||||||
|
const pushQuery = useStableCallback(onQueryChange);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (debounced === pushed.current) return;
|
if (debounced === pushed.current) return;
|
||||||
pushed.current = debounced;
|
pushed.current = debounced;
|
||||||
onQueryChange(debounced);
|
pushQuery(debounced);
|
||||||
// onQueryChange пересоздаётся на каждый рендер родителя — в зависимостях
|
}, [debounced, pushQuery]);
|
||||||
// он гонял бы эффект вхолостую; важен только сам факт смены debounced.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [debounced]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (routeQuery === pushed.current) return;
|
if (routeQuery === pushed.current) return;
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useCallback, useLayoutEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обёртка вокруг колбэка-пропа с НЕИЗМЕННОЙ ссылкой.
|
||||||
|
*
|
||||||
|
* Зачем: колбэк, приходящий сверху пропом (`onCodeLoaded`, `onSaved` и т.п.),
|
||||||
|
* родитель почти всегда пересоздаёт на каждом рендере. Положить такой колбэк
|
||||||
|
* в зависимости useEffect нельзя — эффект начнёт перезапускаться на ровном
|
||||||
|
* месте (для форм портала это означает повторную загрузку заявки с сервера).
|
||||||
|
* Выкинуть его из зависимостей тоже нельзя — эффект зафиксирует ПЕРВУЮ версию
|
||||||
|
* колбэка и после смены пропа будет звать устаревшее замыкание.
|
||||||
|
*
|
||||||
|
* Хук снимает обе проблемы: наружу отдаётся одна и та же функция на всю жизнь
|
||||||
|
* компонента, а внутри она всегда вызывает АКТУАЛЬНУЮ версию колбэка. Такую
|
||||||
|
* обёртку можно честно указывать в зависимостях (см. react-hooks/exhaustive-deps).
|
||||||
|
*
|
||||||
|
* Аналог `useEffectEvent` из будущих версий React; ограничен колбэками без
|
||||||
|
* возвращаемого значения — именно такие у нас в пропах.
|
||||||
|
*
|
||||||
|
* ВАЖНО: вызывать её во время РЕНДЕРА нельзя — только из эффектов и
|
||||||
|
* обработчиков событий, иначе можно прочитать колбэк от прошлого рендера.
|
||||||
|
*/
|
||||||
|
export function useStableCallback<TArgs extends unknown[]>(
|
||||||
|
callback: ((...args: TArgs) => void) | undefined
|
||||||
|
): (...args: TArgs) => void {
|
||||||
|
const ref = useRef(callback);
|
||||||
|
// Обновляем ссылку до того, как отработают обычные (passive) эффекты, —
|
||||||
|
// так вызов из useEffect гарантированно попадает в свежий колбэк.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
ref.current = callback;
|
||||||
|
});
|
||||||
|
return useCallback((...args: TArgs) => ref.current?.(...args), []);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user