fix(table): формула из localStorage не перезаписывает чужое значение ячейки
Пока AURORA-1896 не перенесёт формулы на сервер, временно исправляем потерю данных в EditCell: - resolveEditorState/isSameCellValue (formulaUtils.js) — чистые функции: формула подставляется в редактор только если её результат совпадает с текущим значением ячейки, иначе она устарела (stale) и удаляется из localStorage. - EditCell: открытие и закрытие ячейки без правки больше не шлёт cell_updated (сверка с initialEditorValue при выходе из редактора; для обычных значений — дополнительно через isSameCellValue). Если введённая формула не вычисляется — toast и редактор остаётся открытым, на сервер ничего не уходит. - tableColumns.jsx: ключ формулы строится от getRowId(cell.row) (line_id из данных), а не от cell.row.id (позиционный индекс-путь в дереве, переезжающий при вставке/удалении строк). Префикс ключа — formula_v2_. - RealtimeTable: при монтировании once-очистка устаревших ключей formula_* без суффикса _v2 — они привязаны к позиции строки и стали недостоверны. Тесты: web/src/components/RealtimeTable/utils/formulaUtils.test.js, node --test — 9/9 новых + 5/5 ранее существующих в utils/ зелёные. Closes #264. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
2a287480b6
commit
2e6c94fb07
@ -1,7 +1,25 @@
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useRealtimeActions } from '../../contexts/RealtimeContext';
|
||||
import { calculateFormula, extractFormula, formatNumber, isFormula, normalizeNumericInput } from '../../utils/formulaUtils';
|
||||
import { loadFromStorage, saveToStorage } from '../../utils/localStorageUtils';
|
||||
import {
|
||||
calculateFormula,
|
||||
extractFormula,
|
||||
formatNumber,
|
||||
isFormula,
|
||||
isSameCellValue,
|
||||
normalizeNumericInput,
|
||||
resolveEditorState,
|
||||
} from '../../utils/formulaUtils';
|
||||
import { loadFromStorage, removeFromStorage, saveToStorage } from '../../utils/localStorageUtils';
|
||||
|
||||
// Вычисляет отображаемое значение формулы, не бросая исключение наружу.
|
||||
const computeFormulaDisplay = (formulaValue) => {
|
||||
try {
|
||||
return formatNumber(calculateFormula(extractFormula(formulaValue)));
|
||||
} catch {
|
||||
return '#ОШИБКА';
|
||||
}
|
||||
};
|
||||
|
||||
const EDITOR_STYLES = {
|
||||
position: 'absolute',
|
||||
@ -31,7 +49,7 @@ const TEXTAREA_STYLES = {
|
||||
|
||||
const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, table }) => {
|
||||
const { endEditing: contextEndEditing } = useRealtimeActions();
|
||||
const storageKey = `formula_${tableId}_${cellId}`;
|
||||
const storageKey = `formula_v2_${tableId}_${cellId}`;
|
||||
|
||||
const [state, setState] = useState(() => ({
|
||||
currentValue: value,
|
||||
@ -39,33 +57,36 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
|
||||
isFormulaMode: false,
|
||||
}));
|
||||
|
||||
// Исходное значение редактора на момент открытия — чтобы не отправлять
|
||||
// на сервер открытие и закрытие ячейки без правки.
|
||||
const initialEditorValueRef = useRef(value);
|
||||
|
||||
// Позиция ячейки
|
||||
const [position, setPosition] = useState();
|
||||
const textareaRef = useRef(null);
|
||||
|
||||
const refFinished = useRef(false);
|
||||
|
||||
// Загрузка сохраненной формулы
|
||||
// Загрузка сохраненной формулы: подставляем её только если результат
|
||||
// совпадает с текущим значением ячейки, иначе формула устарела (stale)
|
||||
// и удаляется из хранилища.
|
||||
useEffect(() => {
|
||||
const savedFormula = loadFromStorage(storageKey);
|
||||
if (savedFormula && isFormula(savedFormula)) {
|
||||
try {
|
||||
const formula = extractFormula(savedFormula);
|
||||
const result = calculateFormula(formula);
|
||||
setState({
|
||||
currentValue: savedFormula,
|
||||
displayValue: formatNumber(result),
|
||||
isFormulaMode: true,
|
||||
});
|
||||
} catch {
|
||||
setState({
|
||||
currentValue: savedFormula,
|
||||
displayValue: '#ОШИБКА',
|
||||
isFormulaMode: true,
|
||||
});
|
||||
}
|
||||
const { currentValue, isFormulaMode, stale } = resolveEditorState(savedFormula, value);
|
||||
|
||||
if (stale) {
|
||||
removeFromStorage(storageKey);
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
initialEditorValueRef.current = currentValue;
|
||||
|
||||
if (isFormulaMode) {
|
||||
setState({ currentValue, displayValue: computeFormulaDisplay(currentValue), isFormulaMode: true });
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ currentValue, displayValue: currentValue, isFormulaMode: false });
|
||||
}, [storageKey, value]);
|
||||
|
||||
// Вычисление позиции
|
||||
useLayoutEffect(() => {
|
||||
@ -114,46 +135,67 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [disabled]);
|
||||
|
||||
// Возвращает true, если можно закрывать редактор (сохранено либо не было
|
||||
// изменений), false — если формула некорректна и редактор должен остаться
|
||||
// открытым (ничего не отправляем на сервер в этом случае).
|
||||
const handleSave = useCallback(() => {
|
||||
const { currentValue } = state;
|
||||
|
||||
if (currentValue === initialEditorValueRef.current) {
|
||||
// Ячейку открыли и закрыли без правки — на сервер ничего не шлём.
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isFormula(currentValue)) {
|
||||
saveToStorage(storageKey, currentValue);
|
||||
const formula = extractFormula(currentValue);
|
||||
let result;
|
||||
try {
|
||||
const formula = extractFormula(currentValue);
|
||||
const result = calculateFormula(formula);
|
||||
const formattedResult = formatNumber(result);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
displayValue: formattedResult,
|
||||
isFormulaMode: true,
|
||||
}));
|
||||
onChange?.(formattedResult);
|
||||
result = calculateFormula(formula);
|
||||
} catch {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
displayValue: '#ОШИБКА',
|
||||
isFormulaMode: true,
|
||||
}));
|
||||
onChange?.(currentValue);
|
||||
toast.error('Формула не вычисляется, проверьте выражение');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem(storageKey);
|
||||
|
||||
const formattedResult = formatNumber(result);
|
||||
saveToStorage(storageKey, currentValue);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
displayValue: currentValue,
|
||||
isFormulaMode: false,
|
||||
displayValue: formattedResult,
|
||||
isFormulaMode: true,
|
||||
}));
|
||||
onChange?.(normalizeNumericInput(currentValue));
|
||||
onChange?.(formattedResult);
|
||||
return true;
|
||||
}
|
||||
}, [state, storageKey, onChange]);
|
||||
|
||||
removeFromStorage(storageKey);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
displayValue: currentValue,
|
||||
isFormulaMode: false,
|
||||
}));
|
||||
|
||||
const normalized = normalizeNumericInput(currentValue);
|
||||
if (isSameCellValue(normalized, value)) {
|
||||
// Значение фактически не изменилось (напр. '150' вместо 150).
|
||||
return true;
|
||||
}
|
||||
|
||||
onChange?.(normalized);
|
||||
return true;
|
||||
}, [state, storageKey, onChange, value]);
|
||||
|
||||
const finishEditing = useCallback(() => {
|
||||
if (refFinished.current) return;
|
||||
refFinished.current = true;
|
||||
if (!disabled) {
|
||||
handleSave();
|
||||
const canFinish = handleSave();
|
||||
if (!canFinish) return;
|
||||
}
|
||||
refFinished.current = true;
|
||||
const editingCell = table.getState().editingCell;
|
||||
if (editingCell) {
|
||||
contextEndEditing?.(editingCell.row, editingCell.column);
|
||||
|
||||
@ -25,6 +25,7 @@ import { useBudgetYear } from './hooks/useBudgetYear';
|
||||
import { useValidationRules } from './hooks/useValidationRules';
|
||||
import { getRowId } from './utils/rowUtils';
|
||||
import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils';
|
||||
import { clearLegacyFormulaKeys } from './utils/localStorageUtils';
|
||||
|
||||
import { DictVspApi } from '../../api/dict-vsp';
|
||||
import { useAuth } from '../../app/context/AuthProvider';
|
||||
@ -170,6 +171,12 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
columnVirtualizerRef.current?.measure?.();
|
||||
}, [sizeMult]);
|
||||
|
||||
// Разовая очистка устаревших ключей формул, привязанных к позиции строки
|
||||
// (AURORA-1895): они стали недостоверны после перехода на ключи formula_v2_*.
|
||||
useEffect(() => {
|
||||
clearLegacyFormulaKeys();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!formType || !sheetName) return;
|
||||
let isCancelled = false;
|
||||
|
||||
@ -9,6 +9,7 @@ import { blueColumn } from './constants/columnColors';
|
||||
import { sectionCodeColor } from './constants/columnConfig';
|
||||
|
||||
import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell';
|
||||
import { getRowId } from './utils/rowUtils';
|
||||
|
||||
const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable }) => {
|
||||
const { formId, formType, sheetName, direction } = useParams();
|
||||
@ -52,7 +53,9 @@ const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable
|
||||
onChange: handleChange,
|
||||
tableId: tableKey,
|
||||
table,
|
||||
cellId: `${cell.row.id}_${cell.column.id}`,
|
||||
// Привязан к line_id из данных строки, а не к её позиции в дереве
|
||||
// (cell.row.id — индекс-путь, который переезжает при вставке/удалении строк).
|
||||
cellId: `${getRowId(cell.row)}_${cell.column.id}`,
|
||||
}),
|
||||
[cell, isEditable, handleChange, tableKey, table],
|
||||
);
|
||||
|
||||
@ -81,6 +81,61 @@ export const evaluateCellValue = (value) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Начальное состояние редактора ячейки.
|
||||
* Решает, показывать ли сохранённую формулу или текущее значение ячейки,
|
||||
* и помечает формулу как устаревшую (stale), если её результат расходится
|
||||
* с текущим значением — значит, значение изменил кто-то другой, либо
|
||||
* формула больше не вычисляется.
|
||||
* @param {string|null} savedFormula - строка вида '=...' из localStorage, либо null
|
||||
* @param {string|number|null} value - текущее значение ячейки (с сервера)
|
||||
* @returns {{ currentValue: string|number|null, isFormulaMode: boolean, stale: boolean }}
|
||||
*/
|
||||
export const resolveEditorState = (savedFormula, value) => {
|
||||
if (!savedFormula || !isFormula(savedFormula)) {
|
||||
return { currentValue: value, isFormulaMode: false, stale: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const expression = extractFormula(savedFormula);
|
||||
const result = calculateFormula(expression);
|
||||
const numericValue = Number(value);
|
||||
const stale = Number.isNaN(numericValue) || Math.abs(result - numericValue) > 1e-9;
|
||||
|
||||
if (stale) {
|
||||
return { currentValue: value, isFormulaMode: false, stale: true };
|
||||
}
|
||||
|
||||
return { currentValue: savedFormula, isFormulaMode: true, stale: false };
|
||||
} catch {
|
||||
return { currentValue: value, isFormulaMode: false, stale: true };
|
||||
}
|
||||
};
|
||||
|
||||
const isEmptyCellValue = (value) => value === null || value === undefined || value === '';
|
||||
|
||||
const isNumericCellValue = (value) => !isEmptyCellValue(value) && !Number.isNaN(Number(value));
|
||||
|
||||
/**
|
||||
* Сравнивает два значения ячейки так, как их видит пользователь:
|
||||
* числа сравниваются как числа ('150' == 150, '150.0' == 150), '' == null.
|
||||
* @param {string|number|null} a
|
||||
* @param {string|number|null} b
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isSameCellValue = (a, b) => {
|
||||
const aEmpty = isEmptyCellValue(a);
|
||||
const bEmpty = isEmptyCellValue(b);
|
||||
|
||||
if (aEmpty || bEmpty) return aEmpty && bEmpty;
|
||||
|
||||
if (isNumericCellValue(a) && isNumericCellValue(b)) {
|
||||
return Number(a) === Number(b);
|
||||
}
|
||||
|
||||
return a === b;
|
||||
};
|
||||
|
||||
/**
|
||||
* Форматирует число для отображения
|
||||
* @param {number} value - Число для форматирования
|
||||
|
||||
49
web/src/components/RealtimeTable/utils/formulaUtils.test.js
Normal file
49
web/src/components/RealtimeTable/utils/formulaUtils.test.js
Normal file
@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { isSameCellValue, resolveEditorState } from './formulaUtils.js';
|
||||
|
||||
test('resolveEditorState keeps formula mode when the formula result matches the current value', () => {
|
||||
const result = resolveEditorState('=100+50', 150);
|
||||
assert.equal(result.isFormulaMode, true);
|
||||
assert.equal(result.stale, false);
|
||||
});
|
||||
|
||||
test('resolveEditorState marks the formula stale when the current value diverged (another user changed it)', () => {
|
||||
const result = resolveEditorState('=100+50', 200);
|
||||
assert.equal(result.stale, true);
|
||||
assert.equal(result.currentValue, 200);
|
||||
});
|
||||
|
||||
test('resolveEditorState keeps formula mode when the current value is a numeric string that matches', () => {
|
||||
const result = resolveEditorState('=100+50', '150');
|
||||
assert.equal(result.stale, false);
|
||||
assert.equal(result.isFormulaMode, true);
|
||||
});
|
||||
|
||||
test('resolveEditorState marks the formula stale when it cannot be calculated', () => {
|
||||
const result = resolveEditorState('=abc', 0);
|
||||
assert.equal(result.stale, true);
|
||||
});
|
||||
|
||||
test('resolveEditorState falls back to plain value mode when there is no saved formula', () => {
|
||||
const result = resolveEditorState(null, 5);
|
||||
assert.equal(result.isFormulaMode, false);
|
||||
assert.equal(result.stale, false);
|
||||
assert.equal(result.currentValue, 5);
|
||||
});
|
||||
|
||||
test('isSameCellValue treats a numeric string and a number as equal', () => {
|
||||
assert.equal(isSameCellValue('150', 150), true);
|
||||
});
|
||||
|
||||
test('isSameCellValue treats an empty string and null as equal', () => {
|
||||
assert.equal(isSameCellValue('', null), true);
|
||||
});
|
||||
|
||||
test('isSameCellValue treats a numeric string with a trailing .0 as equal to the plain number', () => {
|
||||
assert.equal(isSameCellValue('150.0', 150), true);
|
||||
});
|
||||
|
||||
test('isSameCellValue treats different numbers as not equal', () => {
|
||||
assert.equal(isSameCellValue('151', 150), false);
|
||||
});
|
||||
@ -81,6 +81,23 @@ export const removeFromStorage = (key) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Удаляет устаревшие ключи формул вида `formula_<tableId>_<cellId>`,
|
||||
* привязанные к позиции строки в дереве таблицы (AURORA-1895). Текущие ключи
|
||||
* формул имеют префикс `formula_v2_` и привязаны к line_id — их не трогаем.
|
||||
* @returns {number} - количество удалённых ключей
|
||||
*/
|
||||
export const clearLegacyFormulaKeys = () => {
|
||||
try {
|
||||
const keysToRemove = Object.keys(localStorage).filter((key) => key.startsWith('formula_') && !key.startsWith('formula_v2_'));
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||
return keysToRemove.length;
|
||||
} catch (error) {
|
||||
console.error('Error clearing legacy formula keys from localStorage:', error);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Очистка всех данных приложения из localStorage
|
||||
* @param {string[]} keysToKeep - Массив ключей, которые нужно сохранить
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user