AURORA-1978: удаление пустой программы и проекта на форме 4
«Удалить строку» на строке программы (GROUP) или проекта (ITEM) ничего не делала: у этих строк нет line_id, окно подтверждения не открывалось, а события program_deleted / project_deleted, которые сервер уже поддерживает, фронт не отправлял. - getDeleteTarget: по выделенной строке выбирает программу, проект или строку бюджета; для непустых программы/проекта сразу показывает причину. - RealtimeContext: deleteProgram / deleteProject и обработка ответов. - useRealtimeData: строка убирается из дерева, пересчёт ROOT/GROUP из ответа сопоставляется по section_code / program_id / project_id (alignNodeRows), а не по sort_order — сервер нумерует строки заново, и у листа без строк бюджета нет ROOT (найдено при проверке на стенде). - Окно подтверждения называет удаляемую программу/проект. - getWsErrorMessage срезает префикс "<class 'asyncpg...'>: " — иначе отказ сервера (программа с проектами у устаревшего клиента) показывался с ним. Refs #303 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
244c03ac14
commit
1aec5d2c2d
@ -2,10 +2,25 @@ import { Alert } from '@mui/material';
|
|||||||
import { DangerOutlinedButton, SecondaryButton } from '../../common/Buttons/Buttons';
|
import { DangerOutlinedButton, SecondaryButton } from '../../common/Buttons/Buttons';
|
||||||
import Modal from '../../common/Modal/Modal';
|
import Modal from '../../common/Modal/Modal';
|
||||||
|
|
||||||
export const ConfirmDeleteRowModal = ({ isOpen, onClose, onConfirm }) => (
|
// AURORA-1978: target — результат getDeleteTarget (строка, программа или проект формы 4).
|
||||||
|
const getConfirmTexts = (target) => {
|
||||||
|
const name = target?.name ? ` «${target.name}»` : '';
|
||||||
|
switch (target?.kind) {
|
||||||
|
case 'program':
|
||||||
|
return { title: 'Удаление программы', message: `Удалить программу${name}?` };
|
||||||
|
case 'project':
|
||||||
|
return { title: 'Удаление проекта', message: `Удалить проект${name}?` };
|
||||||
|
default:
|
||||||
|
return { title: 'Удаление строки', message: 'Удалить выбранную строку? Данные строки будут потеряны.' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConfirmDeleteRowModal = ({ target, onClose, onConfirm }) => {
|
||||||
|
const { title, message } = getConfirmTexts(target);
|
||||||
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title='Удаление строки'
|
title={title}
|
||||||
open={isOpen}
|
open={target != null}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
customSize={25}
|
customSize={25}
|
||||||
actions={
|
actions={
|
||||||
@ -14,6 +29,7 @@ export const ConfirmDeleteRowModal = ({ isOpen, onClose, onConfirm }) => (
|
|||||||
<DangerOutlinedButton onClick={onConfirm}>Удалить</DangerOutlinedButton>
|
<DangerOutlinedButton onClick={onConfirm}>Удалить</DangerOutlinedButton>
|
||||||
</>
|
</>
|
||||||
}>
|
}>
|
||||||
<Alert severity='warning'>Удалить выбранную строку? Данные строки будут потеряны.</Alert>
|
<Alert severity='warning'>{message}</Alert>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -28,7 +28,7 @@ import { useValidationRules } from './hooks/useValidationRules';
|
|||||||
import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils';
|
import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils';
|
||||||
import { isCellEditable } from './utils/cellEditability';
|
import { isCellEditable } from './utils/cellEditability';
|
||||||
import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation';
|
import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation';
|
||||||
import { canAddRowForHeader, getRowId } from './utils/rowUtils';
|
import { canAddRowForHeader, getDeleteTarget } from './utils/rowUtils';
|
||||||
import { clearLegacyFormulaKeys } from './utils/localStorageUtils';
|
import { clearLegacyFormulaKeys } from './utils/localStorageUtils';
|
||||||
import { formatHeaderPaths, getValidationRowKey } from './utils/validationUtils';
|
import { formatHeaderPaths, getValidationRowKey } from './utils/validationUtils';
|
||||||
|
|
||||||
@ -137,7 +137,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(true);
|
||||||
const [expenseItemsVisibilityAction, setExpenseItemsVisibilityAction] = useState('hide');
|
const [expenseItemsVisibilityAction, setExpenseItemsVisibilityAction] = useState('hide');
|
||||||
|
|
||||||
const [rowIdPendingDelete, setRowIdPendingDelete] = useState(null);
|
// AURORA-1978: { kind: 'row' | 'program' | 'project', id, name } — см. getDeleteTarget.
|
||||||
|
const [pendingDelete, setPendingDelete] = useState(null);
|
||||||
const modalsRef = useRef(null);
|
const modalsRef = useRef(null);
|
||||||
|
|
||||||
const isFormNoCanAddRow = useMemo(() => FormsNoCanAddRow[formType]?.includes(sheetName) ?? false, [formType, sheetName]);
|
const isFormNoCanAddRow = useMemo(() => FormsNoCanAddRow[formType]?.includes(sheetName) ?? false, [formType, sheetName]);
|
||||||
@ -171,6 +172,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
updateCell: contextUpdateCell,
|
updateCell: contextUpdateCell,
|
||||||
addRow: contextAddRow,
|
addRow: contextAddRow,
|
||||||
deleteRow: contextDeleteRow,
|
deleteRow: contextDeleteRow,
|
||||||
|
deleteProgram: contextDeleteProgram,
|
||||||
|
deleteProject: contextDeleteProject,
|
||||||
startEditing: contextStartEditing,
|
startEditing: contextStartEditing,
|
||||||
addProgram: contextAddProgram,
|
addProgram: contextAddProgram,
|
||||||
addProject: contextAddProject,
|
addProject: contextAddProject,
|
||||||
@ -962,17 +965,28 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
toast.error('Выделите строку для удаления');
|
toast.error('Выделите строку для удаления');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setRowIdPendingDelete(getRowId(row));
|
const target = getDeleteTarget(row.original, { isForm4: formType === 'FORM_4' });
|
||||||
}, [canModifyRows, getSelectedRow]);
|
if (!target) {
|
||||||
|
toast.error('Эту строку нельзя удалить');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target.blockedReason) {
|
||||||
|
toast.error(target.blockedReason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingDelete(target);
|
||||||
|
}, [canModifyRows, formType, getSelectedRow]);
|
||||||
|
|
||||||
const closeConfirmDeleteRow = useCallback(() => setRowIdPendingDelete(null), []);
|
const closeConfirmDeleteRow = useCallback(() => setPendingDelete(null), []);
|
||||||
|
|
||||||
const confirmDeleteRow = useCallback(() => {
|
const confirmDeleteRow = useCallback(() => {
|
||||||
if (rowIdPendingDelete == null) return;
|
if (!pendingDelete) return;
|
||||||
contextDeleteRow(rowIdPendingDelete);
|
if (pendingDelete.kind === 'program') contextDeleteProgram(pendingDelete.id);
|
||||||
setRowIdPendingDelete(null);
|
else if (pendingDelete.kind === 'project') contextDeleteProject(pendingDelete.id);
|
||||||
|
else contextDeleteRow(pendingDelete.id);
|
||||||
|
setPendingDelete(null);
|
||||||
setRowSelection({});
|
setRowSelection({});
|
||||||
}, [contextDeleteRow, rowIdPendingDelete]);
|
}, [contextDeleteProgram, contextDeleteProject, contextDeleteRow, pendingDelete]);
|
||||||
|
|
||||||
const addRow = useCallback(() => {
|
const addRow = useCallback(() => {
|
||||||
if (!canModifyRows) {
|
if (!canModifyRows) {
|
||||||
@ -1142,7 +1156,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ConfirmDeleteRowModal isOpen={rowIdPendingDelete != null} onClose={closeConfirmDeleteRow} onConfirm={confirmDeleteRow} />
|
<ConfirmDeleteRowModal target={pendingDelete} onClose={closeConfirmDeleteRow} onConfirm={confirmDeleteRow} />
|
||||||
<SheetModals
|
<SheetModals
|
||||||
ref={modalsRef}
|
ref={modalsRef}
|
||||||
formId={formId}
|
formId={formId}
|
||||||
|
|||||||
@ -168,6 +168,14 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// AURORA-1978: удаление программы/проекта формы 4.
|
||||||
|
case 'program_deleted':
|
||||||
|
case 'project_deleted':
|
||||||
|
if (data.result && onNodeDeleteRef.current) {
|
||||||
|
onNodeDeleteRef.current(data);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.log('Unknown event:', data.event);
|
console.log('Unknown event:', data.event);
|
||||||
}
|
}
|
||||||
@ -180,6 +188,7 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
|
|||||||
const onRowDeleteRef = useRef(null);
|
const onRowDeleteRef = useRef(null);
|
||||||
const onProgramAddRef = useRef(null);
|
const onProgramAddRef = useRef(null);
|
||||||
const onProjectAddRef = useRef(null);
|
const onProjectAddRef = useRef(null);
|
||||||
|
const onNodeDeleteRef = useRef(null);
|
||||||
const onErrorRef = useRef(null);
|
const onErrorRef = useRef(null);
|
||||||
const onAuthSuccessRef = useRef(null);
|
const onAuthSuccessRef = useRef(null);
|
||||||
const onCellEditStartRef = useRef(null);
|
const onCellEditStartRef = useRef(null);
|
||||||
@ -403,6 +412,44 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
|
|||||||
[addCommonField, isConnectedRef, sendMessage],
|
[addCommonField, isConnectedRef, sendMessage],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// AURORA-1978: программа/проект формы 4 удаляются своими событиями —
|
||||||
|
// у их строк нет line_id, row_deleted для них не подходит.
|
||||||
|
const deleteProgram = useCallback(
|
||||||
|
(programId) => {
|
||||||
|
if (!isConnectedRef.current) {
|
||||||
|
onErrorRef.current?.('Нет подключения или авторизации');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
event: 'program_deleted',
|
||||||
|
data: { program_id: programId },
|
||||||
|
};
|
||||||
|
addCommonField(message);
|
||||||
|
|
||||||
|
return sendMessage(message);
|
||||||
|
},
|
||||||
|
[addCommonField, isConnectedRef, sendMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteProject = useCallback(
|
||||||
|
(projectId) => {
|
||||||
|
if (!isConnectedRef.current) {
|
||||||
|
onErrorRef.current?.('Нет подключения или авторизации');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
event: 'project_deleted',
|
||||||
|
data: { project_id: projectId },
|
||||||
|
};
|
||||||
|
addCommonField(message);
|
||||||
|
|
||||||
|
return sendMessage(message);
|
||||||
|
},
|
||||||
|
[addCommonField, isConnectedRef, sendMessage],
|
||||||
|
);
|
||||||
|
|
||||||
const subscribeToCellUpdates = useCallback((callback) => {
|
const subscribeToCellUpdates = useCallback((callback) => {
|
||||||
onCellUpdateRef.current = callback;
|
onCellUpdateRef.current = callback;
|
||||||
return () => {
|
return () => {
|
||||||
@ -438,6 +485,13 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const subscribeToNodeDeletes = useCallback((callback) => {
|
||||||
|
onNodeDeleteRef.current = callback;
|
||||||
|
return () => {
|
||||||
|
onNodeDeleteRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const subscribeToErrors = useCallback((callback) => {
|
const subscribeToErrors = useCallback((callback) => {
|
||||||
onErrorRef.current = callback;
|
onErrorRef.current = callback;
|
||||||
return () => {
|
return () => {
|
||||||
@ -482,10 +536,13 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
|
|||||||
addProgram,
|
addProgram,
|
||||||
addProject,
|
addProject,
|
||||||
deleteRow,
|
deleteRow,
|
||||||
|
deleteProgram,
|
||||||
|
deleteProject,
|
||||||
subscribeToRowAdds,
|
subscribeToRowAdds,
|
||||||
subscribeToRowDeletes,
|
subscribeToRowDeletes,
|
||||||
subscribeToProgramAdds,
|
subscribeToProgramAdds,
|
||||||
subscribeToProjectAdds,
|
subscribeToProjectAdds,
|
||||||
|
subscribeToNodeDeletes,
|
||||||
subscribeToErrors,
|
subscribeToErrors,
|
||||||
subscribeToAuthSuccess,
|
subscribeToAuthSuccess,
|
||||||
subscribeToCellEditStart,
|
subscribeToCellEditStart,
|
||||||
@ -499,6 +556,8 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
|
|||||||
addProgram,
|
addProgram,
|
||||||
addProject,
|
addProject,
|
||||||
addRow,
|
addRow,
|
||||||
|
deleteProgram,
|
||||||
|
deleteProject,
|
||||||
deleteRow,
|
deleteRow,
|
||||||
endEditing,
|
endEditing,
|
||||||
lockedCellsStore,
|
lockedCellsStore,
|
||||||
@ -509,6 +568,7 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
|
|||||||
subscribeToCellEditStart,
|
subscribeToCellEditStart,
|
||||||
subscribeToCellUpdates,
|
subscribeToCellUpdates,
|
||||||
subscribeToErrors,
|
subscribeToErrors,
|
||||||
|
subscribeToNodeDeletes,
|
||||||
subscribeToProgramAdds,
|
subscribeToProgramAdds,
|
||||||
subscribeToProjectAdds,
|
subscribeToProjectAdds,
|
||||||
subscribeToRowAdds,
|
subscribeToRowAdds,
|
||||||
|
|||||||
@ -3,7 +3,16 @@ import { toast } from 'react-toastify';
|
|||||||
import { FormsSheetApi } from '../../../api/form_sheet';
|
import { FormsSheetApi } from '../../../api/form_sheet';
|
||||||
import { ProjectsApi } from '../../../api/projects';
|
import { ProjectsApi } from '../../../api/projects';
|
||||||
import { useRealtimeActions, useRealtimeConnection } from '../contexts/RealtimeContext';
|
import { useRealtimeActions, useRealtimeConnection } from '../contexts/RealtimeContext';
|
||||||
import { addRootRow, deleteRow, insertCell, insertProjectRow, updateCells } from '../utils/cellUtils';
|
import {
|
||||||
|
addRootRow,
|
||||||
|
alignNodeRows,
|
||||||
|
deleteProgramRow,
|
||||||
|
deleteProjectRow,
|
||||||
|
deleteRow,
|
||||||
|
insertCell,
|
||||||
|
insertProjectRow,
|
||||||
|
updateCells,
|
||||||
|
} from '../utils/cellUtils';
|
||||||
import { buildRowHierarchy, getParentId, isVspNewRow } from '../utils/rowUtils';
|
import { buildRowHierarchy, getParentId, isVspNewRow } from '../utils/rowUtils';
|
||||||
|
|
||||||
const useRealtimeData = (formId, sheetName, direction, formType, year) => {
|
const useRealtimeData = (formId, sheetName, direction, formType, year) => {
|
||||||
@ -22,6 +31,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
|
|||||||
subscribeToProgramAdds,
|
subscribeToProgramAdds,
|
||||||
subscribeToProjectAdds,
|
subscribeToProjectAdds,
|
||||||
subscribeToRowDeletes,
|
subscribeToRowDeletes,
|
||||||
|
subscribeToNodeDeletes,
|
||||||
subscribeToErrors,
|
subscribeToErrors,
|
||||||
subscribeToCellEditStart,
|
subscribeToCellEditStart,
|
||||||
subscribeToCellEditEnd,
|
subscribeToCellEditEnd,
|
||||||
@ -181,6 +191,22 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// AURORA-1978: ответ на program_deleted / project_deleted —
|
||||||
|
// [id, [[row_type, depth, sort_order, data], ...]]: пересчитанные
|
||||||
|
// ROOT (и GROUP родительской программы для проекта). Сначала убираем
|
||||||
|
// строку, затем вливаем пересчёт, сопоставляя строки по id, а не по
|
||||||
|
// sort_order сервера (см. alignNodeRows).
|
||||||
|
const unsubscribeNodeDeletes = subscribeToNodeDeletes((msg) => {
|
||||||
|
const [, updatedRows = []] = msg.result;
|
||||||
|
setData((prevData) => {
|
||||||
|
const withoutNode =
|
||||||
|
msg.event === 'program_deleted'
|
||||||
|
? deleteProgramRow(prevData, msg.data.program_id)
|
||||||
|
: deleteProjectRow(prevData, msg.data.project_id);
|
||||||
|
return updateCells(withoutNode, alignNodeRows(withoutNode, updatedRows));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Обработчик начала редактирования ячейки
|
// Обработчик начала редактирования ячейки
|
||||||
const unsubscribeCellEditStart = subscribeToCellEditStart((data) => {
|
const unsubscribeCellEditStart = subscribeToCellEditStart((data) => {
|
||||||
const dataCell = data.data;
|
const dataCell = data.data;
|
||||||
@ -220,6 +246,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
|
|||||||
unsubscribeProgramAdds();
|
unsubscribeProgramAdds();
|
||||||
unsubscribeProjectAdds();
|
unsubscribeProjectAdds();
|
||||||
unsubscribeRowDeletes();
|
unsubscribeRowDeletes();
|
||||||
|
unsubscribeNodeDeletes();
|
||||||
unsubscribeErrors();
|
unsubscribeErrors();
|
||||||
unsubscribeCellEditStart();
|
unsubscribeCellEditStart();
|
||||||
unsubscribeCellEditEnd();
|
unsubscribeCellEditEnd();
|
||||||
@ -229,6 +256,7 @@ const useRealtimeData = (formId, sheetName, direction, formType, year) => {
|
|||||||
subscribeToCellUpdates,
|
subscribeToCellUpdates,
|
||||||
subscribeToRowAdds,
|
subscribeToRowAdds,
|
||||||
subscribeToRowDeletes,
|
subscribeToRowDeletes,
|
||||||
|
subscribeToNodeDeletes,
|
||||||
subscribeToErrors,
|
subscribeToErrors,
|
||||||
subscribeToCellEditStart,
|
subscribeToCellEditStart,
|
||||||
subscribeToCellEditEnd,
|
subscribeToCellEditEnd,
|
||||||
|
|||||||
@ -285,15 +285,61 @@ export function insertProjectRow(data, newRow, program_id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function deleteRow(data, lineId) {
|
export function deleteRow(data, lineId) {
|
||||||
|
if (lineId == null) return data;
|
||||||
|
return deleteRowWhere(data, (row) => getDataRowId(row.data) === lineId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AURORA-1978: строка программы формы 4 (GROUP) по program_id.
|
||||||
|
export function deleteProgramRow(data, programId) {
|
||||||
|
return deleteRowWhere(data, (row) => row.row_type === 'GROUP' && row.data?.header?.program_id === programId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AURORA-1978: строка проекта формы 4 (ITEM) по project_id.
|
||||||
|
export function deleteProjectRow(data, projectId) {
|
||||||
|
return deleteRowWhere(data, (row) => row.row_type === 'ITEM' && row.data?.header?.project_id === projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AURORA-1978: строки ответа program_deleted / project_deleted сопоставляются с
|
||||||
|
// локальными по идентичности (ROOT — section_code, GROUP — program_id, ITEM —
|
||||||
|
// project_id), а не по sort_order: сервер нумерует строки заново по всему листу,
|
||||||
|
// а у листа без строк бюджета нет ROOT, поэтому его номера расходятся с локальными.
|
||||||
|
// Возвращает строки ответа с локальным sort_order (для updateCells); строки без
|
||||||
|
// пары в локальных данных отбрасываются.
|
||||||
|
const getNodeRowKey = (rowType, header) => {
|
||||||
|
if (rowType === 'ROOT') return `ROOT|${header?.section_code}`;
|
||||||
|
if (rowType === 'GROUP') return `GROUP|${header?.program_id}`;
|
||||||
|
if (rowType === 'ITEM') return `ITEM|${header?.project_id}`;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function alignNodeRows(data, rows) {
|
||||||
|
const localSortOrders = new Map();
|
||||||
|
const collect = (items) => {
|
||||||
|
for (const row of items) {
|
||||||
|
const key = getNodeRowKey(row.row_type, row.data?.header);
|
||||||
|
if (key && !localSortOrders.has(key)) localSortOrders.set(key, row.sort_order);
|
||||||
|
if (Array.isArray(row.subRows)) collect(row.subRows);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
collect(data);
|
||||||
|
|
||||||
|
return rows.flatMap(([rowType, depth, , rowData]) => {
|
||||||
|
const sortOrder = localSortOrders.get(getNodeRowKey(rowType, rowData?.header));
|
||||||
|
return sortOrder == null ? [] : [[rowType, depth, sortOrder, rowData]];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляет первую строку, подходящую под matches, и сдвигает sort_order
|
||||||
|
// последующих строк (обход в глубину идёт в порядке sort_order).
|
||||||
|
function deleteRowWhere(data, matches) {
|
||||||
const updatedRows = JSON.parse(JSON.stringify(data));
|
const updatedRows = JSON.parse(JSON.stringify(data));
|
||||||
|
|
||||||
let rowDeleteSortOrder = false;
|
let rowDeleteSortOrder = false;
|
||||||
function findAndDelete(rows) {
|
function findAndDelete(rows) {
|
||||||
for (let i = 0; i < rows.length; i++) {
|
for (let i = 0; i < rows.length; i++) {
|
||||||
const row = rows[i];
|
const row = rows[i];
|
||||||
const rowId = getDataRowId(row.data);
|
|
||||||
|
|
||||||
if (rowId === lineId) {
|
if (rowDeleteSortOrder === false && matches(row)) {
|
||||||
rowDeleteSortOrder = row.sort_order;
|
rowDeleteSortOrder = row.sort_order;
|
||||||
rows.splice(i, 1);
|
rows.splice(i, 1);
|
||||||
i--;
|
i--;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import { updateCells } from './cellUtils.js';
|
import { alignNodeRows, deleteProgramRow, deleteProjectRow, deleteRow, updateCells } from './cellUtils.js';
|
||||||
|
|
||||||
// Эталон: прежняя реализация updateCells (до AURORA-1838).
|
// Эталон: прежняя реализация updateCells (до AURORA-1838).
|
||||||
function oldUpdateCells(data, newRows) {
|
function oldUpdateCells(data, newRows) {
|
||||||
@ -245,3 +245,143 @@ test('AURORA-1848: два обновления одной строки в одн
|
|||||||
]);
|
]);
|
||||||
assert.deepEqual(out[0].data.q1, { m1: 5, m2: 3 });
|
assert.deepEqual(out[0].data.q1, { m1: 5, m2: 3 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// AURORA-1978: удаление строк программы/проекта формы 4 из дерева по ответу
|
||||||
|
// program_deleted / project_deleted; sort_order последующих строк сдвигается
|
||||||
|
// на 1, как при удалении строки бюджета.
|
||||||
|
const form4Tree = () => [
|
||||||
|
{
|
||||||
|
row_type: 'ROOT',
|
||||||
|
depth: 0,
|
||||||
|
sort_order: 1,
|
||||||
|
data: { line_id: null, header: { section_code: 'S' } },
|
||||||
|
subRows: [
|
||||||
|
{
|
||||||
|
row_type: 'GROUP',
|
||||||
|
depth: 1,
|
||||||
|
sort_order: 2,
|
||||||
|
data: { line_id: null, header: { program_id: 11 } },
|
||||||
|
subRows: [
|
||||||
|
{ row_type: 'ITEM', depth: 2, sort_order: 3, data: { line_id: null, header: { program_id: 11, project_id: 21 } }, subRows: [] },
|
||||||
|
{
|
||||||
|
row_type: 'ITEM',
|
||||||
|
depth: 2,
|
||||||
|
sort_order: 4,
|
||||||
|
data: { line_id: null, header: { program_id: 11, project_id: 22 } },
|
||||||
|
subRows: [{ row_type: 'INPUT', depth: 3, sort_order: 5, data: { line_id: 501, header: { project_id: 22 } }, subRows: [] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ row_type: 'GROUP', depth: 1, sort_order: 6, data: { line_id: null, header: { program_id: 12 } }, subRows: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const shape = (rows) => rows.map((r) => [r.row_type, r.sort_order, shape(r.subRows ?? [])]);
|
||||||
|
|
||||||
|
test('AURORA-1978: deleteProjectRow убирает ITEM проекта и сдвигает sort_order', () => {
|
||||||
|
const data = form4Tree();
|
||||||
|
const result = deleteProjectRow(data, 21);
|
||||||
|
assert.deepEqual(shape(result), [
|
||||||
|
[
|
||||||
|
'ROOT',
|
||||||
|
1,
|
||||||
|
[
|
||||||
|
['GROUP', 2, [['ITEM', 3, [['INPUT', 4, []]]]]],
|
||||||
|
['GROUP', 5, []],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
assert.equal(result[0].subRows[0].subRows[0].data.header.project_id, 22);
|
||||||
|
assert.deepEqual(shape(data), shape(form4Tree()), 'исходные данные не меняются');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AURORA-1978: deleteProgramRow убирает GROUP программы, строки бюджета с тем же program_id не трогает', () => {
|
||||||
|
const result = deleteProgramRow(form4Tree(), 12);
|
||||||
|
assert.deepEqual(shape(result), [
|
||||||
|
[
|
||||||
|
'ROOT',
|
||||||
|
1,
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'GROUP',
|
||||||
|
2,
|
||||||
|
[
|
||||||
|
['ITEM', 3, []],
|
||||||
|
['ITEM', 4, [['INPUT', 5, []]]],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
// program_id 11 есть и у ITEM/INPUT, но удаляется только сама GROUP 12;
|
||||||
|
// несуществующая программа — дерево без изменений.
|
||||||
|
assert.equal(result[0].subRows[0].subRows[0].data.header.program_id, 11);
|
||||||
|
assert.deepEqual(shape(deleteProgramRow(form4Tree(), 99)), shape(form4Tree()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AURORA-1978: deleteRow по line_id не задевает строки программ и проектов', () => {
|
||||||
|
const result = deleteRow(form4Tree(), 501);
|
||||||
|
assert.deepEqual(shape(result), [
|
||||||
|
[
|
||||||
|
'ROOT',
|
||||||
|
1,
|
||||||
|
[
|
||||||
|
[
|
||||||
|
'GROUP',
|
||||||
|
2,
|
||||||
|
[
|
||||||
|
['ITEM', 3, []],
|
||||||
|
['ITEM', 4, []],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
['GROUP', 5, []],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// AURORA-1978: ответ program_deleted / project_deleted нумерует строки заново
|
||||||
|
// по всему листу, и у листа без строк бюджета нет ROOT — sort_order сервера
|
||||||
|
// расходится с локальным. Найдено вживую: после удаления последнего проекта
|
||||||
|
// GROUP «В» (sort_order 2 у сервера) затёр локальную GROUP «Б» (sort_order 2).
|
||||||
|
test('AURORA-1978: alignNodeRows сопоставляет строки ответа по program_id / section_code, а не по sort_order', () => {
|
||||||
|
const local = [
|
||||||
|
{
|
||||||
|
row_type: 'ROOT',
|
||||||
|
depth: 0,
|
||||||
|
sort_order: 1,
|
||||||
|
data: { line_id: null, header: { section_code: 'CAP', name: 'Раздел' } },
|
||||||
|
subRows: [
|
||||||
|
{ row_type: 'GROUP', depth: 1, sort_order: 2, data: { line_id: null, header: { program_id: 2, name: 'Б' } }, subRows: [] },
|
||||||
|
{ row_type: 'GROUP', depth: 1, sort_order: 3, data: { line_id: null, header: { program_id: 3, name: 'В' } }, subRows: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// Сервер после удаления последнего проекта программы «В»: ROOT нет, «В» — вторая строка листа.
|
||||||
|
const serverRows = [['GROUP', 1, 2, { line_id: null, header: { program_id: 3, name: 'В' }, plan: { q1: 0 } }]];
|
||||||
|
|
||||||
|
const aligned = alignNodeRows(local, serverRows);
|
||||||
|
assert.deepEqual(aligned, [['GROUP', 1, 3, serverRows[0][3]]]);
|
||||||
|
|
||||||
|
const result = updateCells(local, aligned);
|
||||||
|
assert.equal(result[0].subRows[0].data.header.name, 'Б');
|
||||||
|
assert.equal(result[0].subRows[0].data.plan, undefined);
|
||||||
|
assert.deepEqual(result[0].subRows[1].data.plan, { q1: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AURORA-1978: alignNodeRows — ROOT по section_code, строки без пары отбрасываются', () => {
|
||||||
|
const local = form4Tree();
|
||||||
|
const rows = [
|
||||||
|
['ROOT', 0, 7, { header: { section_code: 'S' } }],
|
||||||
|
['GROUP', 1, 1, { header: { program_id: 99 } }],
|
||||||
|
['ITEM', 2, 1, { header: { program_id: 11, project_id: 22 } }],
|
||||||
|
];
|
||||||
|
assert.deepEqual(
|
||||||
|
alignNodeRows(local, rows).map(([type, depth, sort]) => [type, depth, sort]),
|
||||||
|
[
|
||||||
|
['ROOT', 0, 1],
|
||||||
|
['ITEM', 2, 4],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@ -37,6 +37,38 @@ export function getRowId(row) {
|
|||||||
return getDataRowId(row.original.data);
|
return getDataRowId(row.original.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AURORA-1978: что удаляет кнопка «Удалить строку» для выделенной строки.
|
||||||
|
// На форме 4 у строк программы (GROUP) и проекта (ITEM) нет line_id — их
|
||||||
|
// удаляют отдельные события program_deleted / project_deleted по id из header.
|
||||||
|
// Непустые программу/проект сервер не удалит (v3.del_form4_program /
|
||||||
|
// v3.del_form4_project), поэтому причину показываем сразу, без запроса.
|
||||||
|
// rowData — row.original (row_type, data, subRows).
|
||||||
|
export function getDeleteTarget(rowData, { isForm4 = false } = {}) {
|
||||||
|
if (!rowData) return null;
|
||||||
|
const header = rowData.data?.header;
|
||||||
|
const hasChildren = Array.isArray(rowData.subRows) && rowData.subRows.length > 0;
|
||||||
|
|
||||||
|
if (isForm4 && rowData.row_type === 'GROUP' && header?.program_id != null) {
|
||||||
|
return {
|
||||||
|
kind: 'program',
|
||||||
|
id: header.program_id,
|
||||||
|
name: header.name ?? '',
|
||||||
|
blockedReason: hasChildren ? 'Нельзя удалить программу: в ней есть проекты или расходы' : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (isForm4 && rowData.row_type === 'ITEM' && header?.project_id != null) {
|
||||||
|
return {
|
||||||
|
kind: 'project',
|
||||||
|
id: header.project_id,
|
||||||
|
name: header.name ?? '',
|
||||||
|
blockedReason: hasChildren ? 'Нельзя удалить проект: в нём есть расходы' : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = getDataRowId(rowData.data);
|
||||||
|
return id == null ? null : { kind: 'row', id };
|
||||||
|
}
|
||||||
|
|
||||||
export function isVspRow(row) {
|
export function isVspRow(row) {
|
||||||
if (row?.original?.data?.id) {
|
if (row?.original?.data?.id) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { buildRowHierarchy, canAddRowForHeader, getDataRowId } from './rowUtils.js';
|
import { buildRowHierarchy, canAddRowForHeader, getDataRowId, getDeleteTarget } from './rowUtils.js';
|
||||||
|
|
||||||
test('row hierarchy uses row types when depth skips a level and keeps INPUT rows as siblings', () => {
|
test('row hierarchy uses row types when depth skips a level and keeps INPUT rows as siblings', () => {
|
||||||
const rows = [
|
const rows = [
|
||||||
@ -14,9 +14,18 @@ test('row hierarchy uses row types when depth skips a level and keeps INPUT rows
|
|||||||
|
|
||||||
const tree = buildRowHierarchy(rows);
|
const tree = buildRowHierarchy(rows);
|
||||||
|
|
||||||
assert.deepEqual(tree.map(({ id }) => id), ['root', 'next-root']);
|
assert.deepEqual(
|
||||||
assert.deepEqual(tree[0].subRows.map(({ id }) => id), ['item-1', 'item-2']);
|
tree.map(({ id }) => id),
|
||||||
assert.deepEqual(tree[0].subRows[0].subRows.map(({ id }) => id), ['input-1', 'input-2']);
|
['root', 'next-root'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
tree[0].subRows.map(({ id }) => id),
|
||||||
|
['item-1', 'item-2'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
tree[0].subRows[0].subRows.map(({ id }) => id),
|
||||||
|
['input-1', 'input-2'],
|
||||||
|
);
|
||||||
assert.deepEqual(tree[0].subRows[0].subRows[0].subRows, []);
|
assert.deepEqual(tree[0].subRows[0].subRows[0].subRows, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -38,3 +47,43 @@ test('AURORA-1848: getDataRowId без id и line_id возвращает null
|
|||||||
assert.equal(getDataRowId({ line_id: 7 }), 7);
|
assert.equal(getDataRowId({ line_id: 7 }), 7);
|
||||||
assert.ok(JSON.stringify({ row_id: getDataRowId({ header: {} }) }).includes('"row_id":null'));
|
assert.ok(JSON.stringify({ row_id: getDataRowId({ header: {} }) }).includes('"row_id":null'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// AURORA-1978: «Удалить строку» на форме 4 для строки программы (GROUP) и
|
||||||
|
// проекта (ITEM). У этих строк нет line_id — раньше кнопка молча ничего не
|
||||||
|
// делала; теперь цель удаления — программа/проект по id из header.
|
||||||
|
const form4Row = (row_type, header, subRows = []) => ({ row_type, data: { line_id: null, header }, subRows });
|
||||||
|
|
||||||
|
test('AURORA-1978: getDeleteTarget — пустая программа и пустой проект формы 4', () => {
|
||||||
|
assert.deepEqual(getDeleteTarget(form4Row('GROUP', { program_id: 11, name: 'Программа А' }), { isForm4: true }), {
|
||||||
|
kind: 'program',
|
||||||
|
id: 11,
|
||||||
|
name: 'Программа А',
|
||||||
|
blockedReason: null,
|
||||||
|
});
|
||||||
|
assert.deepEqual(getDeleteTarget(form4Row('ITEM', { program_id: 11, project_id: 21, name: 'Проект Б' }), { isForm4: true }), {
|
||||||
|
kind: 'project',
|
||||||
|
id: 21,
|
||||||
|
name: 'Проект Б',
|
||||||
|
blockedReason: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AURORA-1978: getDeleteTarget — непустые программа и проект запрещены с причиной', () => {
|
||||||
|
const project = form4Row('ITEM', { program_id: 11, project_id: 21, name: 'Проект Б' }, [
|
||||||
|
{ row_type: 'INPUT', data: { line_id: 501, header: {} }, subRows: [] },
|
||||||
|
]);
|
||||||
|
const program = form4Row('GROUP', { program_id: 11, name: 'Программа А' }, [project]);
|
||||||
|
|
||||||
|
assert.equal(getDeleteTarget(program, { isForm4: true }).blockedReason, 'Нельзя удалить программу: в ней есть проекты или расходы');
|
||||||
|
assert.equal(getDeleteTarget(project, { isForm4: true }).blockedReason, 'Нельзя удалить проект: в нём есть расходы');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AURORA-1978: getDeleteTarget — строка бюджета и прочие формы удаляются по line_id, как раньше', () => {
|
||||||
|
const input = { row_type: 'INPUT', data: { line_id: 501, header: { program_id: 11, project_id: 21 } }, subRows: [] };
|
||||||
|
assert.deepEqual(getDeleteTarget(input, { isForm4: true }), { kind: 'row', id: 501 });
|
||||||
|
assert.deepEqual(getDeleteTarget({ row_type: 'INPUT', data: { id: 7 } }), { kind: 'row', id: 7 });
|
||||||
|
// GROUP вне формы 4 (иерархия статей) — удалять нечего.
|
||||||
|
assert.equal(getDeleteTarget(form4Row('GROUP', { program_id: 11 })), null);
|
||||||
|
assert.equal(getDeleteTarget(form4Row('ROOT', { section_code: 'S' }), { isForm4: true }), null);
|
||||||
|
assert.equal(getDeleteTarget(null, { isForm4: true }), null);
|
||||||
|
});
|
||||||
|
|||||||
@ -9,6 +9,11 @@ const KNOWN_PREFIX_MESSAGES = [
|
|||||||
|
|
||||||
const DEFAULT_ERROR_MESSAGE = 'Неизвестная ошибка сервера';
|
const DEFAULT_ERROR_MESSAGE = 'Неизвестная ошибка сервера';
|
||||||
|
|
||||||
|
// AURORA-1978: backend (map_sqlalchemy_error) отдаёт str(exc.orig) — для RAISE
|
||||||
|
// из PL/pgSQL это "<class 'asyncpg.exceptions.RaiseError'>: <текст>".
|
||||||
|
const EXCEPTION_CLASS_PREFIX = /^<class '[^']*'>:\s*/;
|
||||||
|
const stripExceptionClass = (text) => String(text ?? '').replace(EXCEPTION_CLASS_PREFIX, '');
|
||||||
|
|
||||||
// Если текст начинается с известного технического префикса — возвращает человеческую замену, иначе null.
|
// Если текст начинается с известного технического префикса — возвращает человеческую замену, иначе null.
|
||||||
const resolveKnownPrefix = (text) => {
|
const resolveKnownPrefix = (text) => {
|
||||||
const normalized = String(text ?? '').trim();
|
const normalized = String(text ?? '').trim();
|
||||||
@ -22,12 +27,13 @@ const resolveKnownPrefix = (text) => {
|
|||||||
// Никогда не возвращает undefined/null и не добавляет технический префикс "Server error:".
|
// Никогда не возвращает undefined/null и не добавляет технический префикс "Server error:".
|
||||||
export const getWsErrorMessage = (error) => {
|
export const getWsErrorMessage = (error) => {
|
||||||
if (typeof error === 'string') {
|
if (typeof error === 'string') {
|
||||||
return resolveKnownPrefix(error) ?? (error.trim() || DEFAULT_ERROR_MESSAGE);
|
const text = stripExceptionClass(error);
|
||||||
|
return resolveKnownPrefix(text) ?? (text.trim() || DEFAULT_ERROR_MESSAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error && typeof error === 'object') {
|
if (error && typeof error === 'object') {
|
||||||
const raw = error.message || error.detail;
|
const raw = stripExceptionClass(error.message || error.detail);
|
||||||
if (!raw) return DEFAULT_ERROR_MESSAGE;
|
if (!raw.trim()) return DEFAULT_ERROR_MESSAGE;
|
||||||
return resolveKnownPrefix(raw) ?? raw;
|
return resolveKnownPrefix(raw) ?? raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -51,3 +51,23 @@ test('пустая строка и отсутствующая ошибка не
|
|||||||
assert.notEqual(getWsErrorMessage(undefined), undefined);
|
assert.notEqual(getWsErrorMessage(undefined), undefined);
|
||||||
assert.ok(!String(getWsErrorMessage(undefined)).includes('undefined'));
|
assert.ok(!String(getWsErrorMessage(undefined)).includes('undefined'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// AURORA-1978: map_sqlalchemy_error берёт str(exc.orig), и для RAISE из
|
||||||
|
// PL/pgSQL текст приходит как "<class 'asyncpg.exceptions.RaiseError'>: ...".
|
||||||
|
// Технический префикс класса в тост не попадает; известные префиксы после него
|
||||||
|
// по-прежнему переводятся.
|
||||||
|
test('AURORA-1978: префикс класса исключения asyncpg срезается', () => {
|
||||||
|
assert.equal(
|
||||||
|
getWsErrorMessage({
|
||||||
|
code: 400,
|
||||||
|
field: '',
|
||||||
|
message: "<class 'asyncpg.exceptions.RaiseError'>: Нельзя удалить программу, содержащую проекты",
|
||||||
|
}),
|
||||||
|
'Нельзя удалить программу, содержащую проекты',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getWsErrorMessage("<class 'asyncpg.exceptions.RaiseError'>: role_not_allowed: колонка plan.q1"),
|
||||||
|
'Нет прав на изменение этой колонки',
|
||||||
|
);
|
||||||
|
assert.equal(getWsErrorMessage("<class 'asyncpg.exceptions.RaiseError'>: "), 'Неизвестная ошибка сервера');
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user