fix(table): контроль ошибок — агрегатные строки, переход и прокрутка к ячейке, подстановка {year}/{unit} (AURORA-1893)

Доработка по возврату тестирования 06.11:
- у агрегатных строк (ROOT/GROUP/ITEM/SUB_ITEM) нет data.id, ключ ошибки был
  «правило:колонка:null» — все агрегаты одной колонки схлопывались в одну
  запись. Ключ строки теперь getValidationRowKey: line id или sort:<sort_order>;
- переход к ошибке ищет строку по тому же ключу (раньше для агрегата
  прерывался, выделение оставалось на предыдущей ячейке);
- прокрутка: вместо scrollToIndex(rowIndex + 5, 'auto') — центрирование и
  scrollIntoView самой ячейки после отрисовки (у последних строк запас +5
  обрезался, шапка внутри контейнера не учитывалась — ячейка оставалась ниже
  видимой области);
- названия колонок в плашке и в тосте EditCell — с подставленными годом и
  единицами (formatHeaderPaths, как в TableHead);
- рамка активной ячейки дублируется псевдоэлементом ::after: inset-тень td
  перекрывалась красной рамкой ошибочной ячейки.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-11-08 16:38:49 +04:00
parent f9fbd7399f
commit a61c09be18
6 changed files with 161 additions and 10 deletions

View File

@ -30,6 +30,7 @@ import { isCellEditable } from './utils/cellEditability';
import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation';
import { canAddRowForHeader, getRowId } from './utils/rowUtils';
import { clearLegacyFormulaKeys } from './utils/localStorageUtils';
import { formatHeaderPaths, getValidationRowKey } from './utils/validationUtils';
import { DictVspApi } from '../../api/dict-vsp';
import { useAuth } from '../../app/context/AuthProvider';
@ -78,6 +79,21 @@ const loadingOverlayStyle = {
zIndex: 2,
};
// AURORA-1893 (возврат 06.11): после scrollToIndex строка появляется в DOM не сразу
// (виртуализатор перерисовывает диапазон по событию scroll) — ждём до нескольких кадров
// и прокручиваем к самой ячейке: scrollIntoView учитывает реальную раскладку, включая шапку.
const SCROLL_CELL_MAX_FRAMES = 10;
const scrollCellIntoViewWhenRendered = (container, rowId, columnId, framesLeft = SCROLL_CELL_MAX_FRAMES) => {
if (!container || framesLeft <= 0) return;
const escapeIdent = globalThis.CSS?.escape ?? String;
const cell = container.querySelector(`tbody tr[data-row-id="${escapeIdent(rowId)}"] > td[data-col-id="${escapeIdent(columnId)}"]`);
if (cell) {
cell.scrollIntoView({ block: 'nearest', inline: 'nearest' });
return;
}
requestAnimationFrame(() => scrollCellIntoViewWhenRendered(container, rowId, columnId, framesLeft - 1));
};
const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const budgetYear = useBudgetYear(formId, formType, year);
const { user } = useAuth();
@ -116,7 +132,7 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
// -> cell_edit_start следующей.
const pendingEditTargetRef = useRef(null);
const [columnsConfig, setColumnsConfig] = useState(null);
const { errors: validationErrors, getCellErrors, headerPaths } = useValidationRules(data, columnsConfig?.columns, formType, sheetName);
const { errors: validationErrors, getCellErrors, headerPaths: rawHeaderPaths } = useValidationRules(data, columnsConfig?.columns, formType, sheetName);
const [expanded, setExpanded] = useState(true);
const [expenseItemsVisibilityAction, setExpenseItemsVisibilityAction] = useState('hide');
@ -221,6 +237,12 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
const { sizeMult, setSizeMult, tableScaleStyle, tableWrapperStyle } = useTableScale();
const denominationStorageKey = `${formId}_${formType}_${sheetName}_${direction}_denomination_v2`;
const { denomination, setDenomination: setTableDenomination } = useDenomination(denominationStorageKey);
// AURORA-1893 (возврат 06.11): названия колонок для «Контроля ошибок» и тоста EditCell —
// с подставленными годом и единицами, как в шапке таблицы.
const headerPaths = useMemo(
() => formatHeaderPaths(rawHeaderPaths, budgetYear, denomination),
[rawHeaderPaths, budgetYear, denomination],
);
useEffect(() => {
rowVirtualizerRef.current?.measure?.();
@ -686,10 +708,13 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
// (expandRows() в @tanstack/table-core отфильтровывает именно .rows, сохраняя flatRows
// целиком), иначе ошибку в свёрнутой группе было бы невозможно найти, чтобы её раскрыть.
const handleNavigateToError = useCallback(
({ lineId, columnId } = {}) => {
if (lineId === undefined || lineId === null || !columnId) return;
({ rowKey, columnId } = {}) => {
if (rowKey === undefined || rowKey === null || !columnId) return;
const row = table.getRowModel().flatRows.find((candidate) => getRowId(candidate) === lineId);
// AURORA-1893 (возврат 06.11): строку ищем по тому же ключу, что и ошибку
// (getValidationRowKey): у агрегатных строк нет line id, раньше переход к ним
// молча прерывался и выделение оставалось на предыдущей ячейке.
const row = table.getRowModel().flatRows.find((candidate) => getValidationRowKey(candidate.original) === rowKey);
if (!row) return;
const parentRows = row.getParentRows();
@ -723,10 +748,15 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
requestAnimationFrame(() => {
const rowIndex = table.getRowModel().rows.findIndex((visibleRow) => visibleRow.id === row.id);
if (rowIndex < 0) return;
rowVirtualizerRef.current?.scrollToIndex?.(rowIndex < 5 ? rowIndex : rowIndex + 5, { align: 'auto', behavior: 'smooth' });
// AURORA-1893 (возврат 06.11): раньше было scrollToIndex(rowIndex + 5, align 'auto') —
// у последних строк листа запас +5 обрезался, а шапка внутри контейнера прокрутки
// виртуализатором не учитывается, и строка оставалась за нижним краем. Центрируем
// строку, а после отрисовки доводим до видимой области саму ячейку.
rowVirtualizerRef.current?.scrollToIndex?.(rowIndex, { align: 'center' });
scrollCellIntoViewWhenRendered(containerRef.current, row.id, columnId);
});
},
[table, columnVisibility, handleSetColumnsVisibility, handleNavigateToColumn],
[table, columnVisibility, handleSetColumnsVisibility, handleNavigateToColumn, containerRef],
);
const searchMatches = useMemo(() => {

View File

@ -52,7 +52,7 @@ const ValidationErrorsAlert = ({ errors = [], onNavigateToError, headerPaths })
const error = errors[index];
if (!error) return;
setActiveIndex(index);
onNavigateToError?.({ lineId: error.lineId, columnId: error.columnId });
onNavigateToError?.({ rowKey: error.rowKey, columnId: error.columnId });
};
const handleStep = (direction) => {

View File

@ -36,7 +36,21 @@ const escapeCssIdentFallback = (value) => {
const defaultEscape = (value) =>
typeof globalThis.CSS?.escape === 'function' ? globalThis.CSS.escape(value) : escapeCssIdentFallback(value);
export const ACTIVE_CELL_STYLE = { zIndex: 7, boxShadow: 'inset 0 0 0 3px #1976d2' };
// AURORA-1893 (возврат 06.11): одной inset-тени td мало — у ошибочной ячейки её перекрывает
// красная рамка вложенного div (INVALID_STYLES), и выбранная ячейка не выделялась.
// Рамка дублируется псевдоэлементом поверх содержимого; td и так position relative/sticky.
export const ACTIVE_CELL_STYLE = {
zIndex: 7,
boxShadow: 'inset 0 0 0 3px #1976d2',
'&::after': {
content: '""',
position: 'absolute',
inset: 0,
border: '3px solid #1976d2',
pointerEvents: 'none',
zIndex: 2,
},
};
// Правило sx для контейнера таблицы: подсветка текущего совпадения поиска.
// Вместо sx на каждой ячейке перерисовывается один селектор. null/неполное совпадение -> {}.

View File

@ -29,3 +29,16 @@ test('highlight keeps 3px frame and z-index 7', () => {
assert.equal(ACTIVE_CELL_STYLE.zIndex, 7);
assert.equal(ACTIVE_CELL_STYLE.boxShadow, 'inset 0 0 0 3px #1976d2');
});
// AURORA-1893 (возврат 06.11): inset box-shadow у td перекрывается красной рамкой
// ошибочной ячейки (INVALID_STYLES на вложенном div) — выбранный агрегат с ошибкой
// не выделялся. Рамка рисуется псевдоэлементом поверх содержимого ячейки.
test('active cell frame is drawn by an ::after overlay above the cell content', () => {
const overlay = ACTIVE_CELL_STYLE['&::after'];
assert.ok(overlay, 'ACTIVE_CELL_STYLE must define an ::after overlay');
assert.equal(overlay.content, '""');
assert.equal(overlay.position, 'absolute');
assert.equal(overlay.inset, 0);
assert.equal(overlay.pointerEvents, 'none');
assert.match(overlay.border, /3px solid #1976d2/);
});

View File

@ -1,3 +1,4 @@
import { formatHeaderUnit, formatHeaderYear } from './headerYearUtils.js';
import { getDataRowId } from './rowUtils.js';
// Достаёт вложенное поле из объекта строки таблицы
@ -33,6 +34,28 @@ export const buildColumnHeaderPaths = (columns = [], path = [], map = {}) => {
return map;
};
// AURORA-1893 (возврат 06.11): заголовки колонок в конфигах содержат плейсхолдеры
// {year}/{year:short}/{unit} — подставляем их так же, как TableHead, чтобы в «Контроле
// ошибок» и в тосте EditCell не было «План {year} ({unit})».
export const formatHeaderPaths = (headerPaths, budgetYear, denomination) => {
const formatted = {};
for (const [columnId, path] of Object.entries(headerPaths || {})) {
formatted[columnId] = path.map((segment) => formatHeaderUnit(formatHeaderYear(segment, budgetYear), denomination));
}
return formatted;
};
// AURORA-1893 (возврат 06.11): ключ строки для ошибки и перехода к ней. У строк ввода
// это line id, а у агрегатных строк (ROOT/GROUP/ITEM/SUB_ITEM) data.id нет — раньше ключ
// у всех агрегатов одной колонки был null, они схлопывались в одну запись и переход к ним
// не срабатывал. sort_order — глобальный ROW_NUMBER листа, уникален для каждой строки.
export const getValidationRowKey = (row) => {
const lineId = getDataRowId(row?.data);
if (lineId !== null && lineId !== undefined) return lineId;
if (row?.sort_order !== undefined && row?.sort_order !== null) return `sort:${row.sort_order}`;
return null;
};
// AURORA-1893: человекочитаемая подпись строки для меню плашки ошибок —
// название статьи/ВСП, если есть, иначе номер строки (колонка «№»).
const getRowLabel = (row) => {
@ -53,7 +76,8 @@ export const collectInvalidErrors = (rows, rule, errors, seen) => {
if (!rule.isInvalid(getValueByPath(row, columnKey), row, columnKey)) continue;
const lineId = getDataRowId(row.data);
const errorId = `${rule.id}:${columnKey}:${lineId}`;
const rowKey = getValidationRowKey(row);
const errorId = `${rule.id}:${columnKey}:${rowKey}`;
if (seen.has(errorId)) continue;
seen.add(errorId);
@ -62,6 +86,7 @@ export const collectInvalidErrors = (rows, rule, errors, seen) => {
ruleId: rule.id,
columnId: columnKey,
lineId,
rowKey,
rowLabel: getRowLabel(row),
message: rule.message,
});

View File

@ -1,7 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { nonNegativeColumnKeys, nonNegativeRule, validationRules } from '../constants/validation.js';
import { collectInvalidErrors, groupValidationErrors, isValidationRuleEnabled } from './validationUtils.js';
import {
collectInvalidErrors,
formatHeaderPaths,
getValidationRowKey,
groupValidationErrors,
isValidationRuleEnabled,
} from './validationUtils.js';
test('nonNegativeRule flags negative numbers as invalid', () => {
const rule = nonNegativeRule({
@ -212,3 +218,66 @@ test('booking-less-balance only reports its errorColumnKeys (booking columns), n
assert.equal(errors[0].columnId, 'data.q1.booking');
assert.equal(errors[0].lineId, 5);
});
// AURORA-1893 (возврат 06.11): у агрегатных строк (ROOT/GROUP/ITEM/SUB_ITEM) нет
// data.id/line_id — ключ строки берётся из глобального sort_order.
test('getValidationRowKey uses the line id, else sort_order for aggregate rows', () => {
assert.equal(getValidationRowKey({ data: { id: 15 }, sort_order: 53 }), 15);
assert.equal(getValidationRowKey({ data: { line_id: 7 } }), 7);
assert.equal(getValidationRowKey({ row_type: 'GROUP', data: { header: { name: 'Г' } }, sort_order: 2 }), 'sort:2');
assert.equal(getValidationRowKey({ row_type: 'ROOT', data: {}, sort_order: 0 }), 'sort:0');
assert.equal(getValidationRowKey({ data: {} }), null);
});
test('collectInvalidErrors reports every invalid aggregate row, not only the first one per column', () => {
const rule = { id: 'neg', columnKeys: ['data.v'], message: 'm', isInvalid: (value) => Number(value) < 0 };
const rows = [
{
row_type: 'ROOT',
sort_order: 1,
data: { header: { name: 'Корень' }, v: -200 },
subRows: [
{
row_type: 'GROUP',
sort_order: 2,
data: { header: { name: 'Группа 1' }, v: -100 },
subRows: [{ row_type: 'INPUT', sort_order: 3, data: { id: 9, v: -100 }, subRows: [] }],
},
{
row_type: 'GROUP',
sort_order: 7,
data: { header: { name: 'Группа 2' }, v: -100 },
subRows: [{ row_type: 'INPUT', sort_order: 8, data: { id: 10, v: -100 }, subRows: [] }],
},
],
},
];
const errors = [];
collectInvalidErrors(rows, rule, errors, new Set());
assert.deepEqual(
errors.map((error) => [error.rowKey, error.rowLabel]),
[
['sort:1', 'Корень'],
['sort:2', 'Группа 1'],
[9, '3'],
['sort:7', 'Группа 2'],
[10, '8'],
],
);
assert.equal(new Set(errors.map((error) => error.id)).size, 5);
});
test('formatHeaderPaths substitutes year and unit placeholders in column titles', () => {
const headerPaths = {
'data.plan.q1': ['Планируемые показатели', 'План {year} ({unit}) с учётом НДС', '1 Кв. {year:short}'],
'data.name': ['Наименование'],
};
const formatted = formatHeaderPaths(headerPaths, 2027, 'thousand');
assert.deepEqual(formatted['data.plan.q1'], ['Планируемые показатели', 'План 2027 (тыс. руб.) с учётом НДС', '1 Кв. 27']);
assert.deepEqual(formatted['data.name'], ['Наименование']);
assert.deepEqual(headerPaths['data.plan.q1'][1], 'План {year} ({unit}) с учётом НДС', 'исходные пути не мутируются');
});