feat(formulas): маркер fx у ячейки, формула с сервера в редакторе, без localStorage (AURORA-1896)
- RealtimeContext: стор карты формул (formulaStore), загрузка при открытии листа и
переподключении (формы и отчёты проектов), обновление на каждом cell_updated,
защита от устаревших ответов при смене листа; useCellFormula; updateCell(..., { formula })
- Cell: маркер fx только при актуальной формуле, подсказка с единицей при displayFactor != 1
- EditCell: начальный текст «=выражение», выражение уходит в data.formula, без localStorage;
ввод «100+50» без «=» не отправляется (тост «Формула начинается с =»)
- удалены resolveEditorState и clearLegacyFormulaKeys; T6.1/T6.2 в cellFormulaUtils
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b93e22080e
commit
3bee5e795f
@ -1,9 +1,11 @@
|
|||||||
import { Tooltip } from '@mui/material';
|
import { Tooltip } from '@mui/material';
|
||||||
import React, { useMemo, useCallback, useState } from 'react';
|
import React, { useMemo, useCallback, useState } from 'react';
|
||||||
import { useCellLock } from '../../contexts/RealtimeContext';
|
import { getColumnRequestKey, useCellFormula, useCellLock } from '../../contexts/RealtimeContext';
|
||||||
|
import { formatFormulaHintWithUnit, isFormulaActual } from '../../utils/cellFormulaUtils';
|
||||||
import { resolveCellTextColor } from '../../utils/cellTextColor';
|
import { resolveCellTextColor } from '../../utils/cellTextColor';
|
||||||
import { formatNumber } from '../../utils/numberFormat';
|
|
||||||
import { isTextColumn } from '../../utils/moneyUtils';
|
import { isTextColumn } from '../../utils/moneyUtils';
|
||||||
|
import { formatNumber } from '../../utils/numberFormat';
|
||||||
|
import { getRowId } from '../../utils/rowUtils';
|
||||||
|
|
||||||
const BASE_CELL_STYLES = {
|
const BASE_CELL_STYLES = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@ -75,6 +77,23 @@ const LOCK_BADGE_STYLES = {
|
|||||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// AURORA-1896: маркер «fx» у ячейки со значением, посчитанным по сохранённой формуле.
|
||||||
|
// Угол слева-снизу: значок блокировки (AURORA-1890) занят справа-сверху, рамка редактируемости —
|
||||||
|
// граница ячейки, поэтому маркер ничего из этого не перекрывает.
|
||||||
|
const FORMULA_MARK_STYLES = {
|
||||||
|
position: 'absolute',
|
||||||
|
left: '3px',
|
||||||
|
bottom: '1px',
|
||||||
|
fontSize: '9px',
|
||||||
|
lineHeight: 1,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#4a90d9',
|
||||||
|
cursor: 'help',
|
||||||
|
userSelect: 'none',
|
||||||
|
zIndex: 5,
|
||||||
|
};
|
||||||
|
|
||||||
const CONTAINER_STYLES = {
|
const CONTAINER_STYLES = {
|
||||||
display: '-webkit-box',
|
display: '-webkit-box',
|
||||||
WebkitBoxOrient: 'vertical',
|
WebkitBoxOrient: 'vertical',
|
||||||
@ -144,6 +163,7 @@ const CellComponent = ({
|
|||||||
invalidMessage,
|
invalidMessage,
|
||||||
vspOptions,
|
vspOptions,
|
||||||
displayFactor = 1,
|
displayFactor = 1,
|
||||||
|
moneyUnit = null,
|
||||||
}) => {
|
}) => {
|
||||||
const cellKey = `${row.id}_${column.id}`;
|
const cellKey = `${row.id}_${column.id}`;
|
||||||
const isLocked = useCellLock(cellKey);
|
const isLocked = useCellLock(cellKey);
|
||||||
@ -183,6 +203,10 @@ const CellComponent = ({
|
|||||||
return { rawValue: value, displayValue: display, isNumeric: isNum, isNegative: negative };
|
return { rawValue: value, displayValue: display, isNumeric: isNum, isNegative: negative };
|
||||||
}, [cell, column.id, isVspDropdown, vspOptions, displayFactor]);
|
}, [cell, column.id, isVspDropdown, vspOptions, displayFactor]);
|
||||||
|
|
||||||
|
// AURORA-1896: формула с сервера; маркер только если она даёт текущее значение ячейки.
|
||||||
|
const formula = useCellFormula(getRowId(row), getColumnRequestKey(column));
|
||||||
|
const showFormulaMark = isFormulaActual(formula, rawValue);
|
||||||
|
|
||||||
const highlightedContent = useMemo(() => {
|
const highlightedContent = useMemo(() => {
|
||||||
const searchQueries = [globalFilter, columnFilter];
|
const searchQueries = [globalFilter, columnFilter];
|
||||||
return createHighlightedContent(rawValue, displayValue, searchQueries);
|
return createHighlightedContent(rawValue, displayValue, searchQueries);
|
||||||
@ -230,6 +254,11 @@ const CellComponent = ({
|
|||||||
<span style={CONTAINER_STYLES}>{highlightedContent}</span>
|
<span style={CONTAINER_STYLES}>{highlightedContent}</span>
|
||||||
{isOverLimit && <span style={OVER_LIMIT_CHIP_STYLES}>сверх лимита</span>}
|
{isOverLimit && <span style={OVER_LIMIT_CHIP_STYLES}>сверх лимита</span>}
|
||||||
{lockBadge}
|
{lockBadge}
|
||||||
|
{showFormulaMark && (
|
||||||
|
<Tooltip title={formatFormulaHintWithUnit(formula, displayFactor, moneyUnit)} arrow placement='top'>
|
||||||
|
<span style={FORMULA_MARK_STYLES}>fx</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -261,6 +290,7 @@ const Cell = React.memo(CellComponent, (prevProps, nextProps) => {
|
|||||||
prevProps.isEditable === nextProps.isEditable &&
|
prevProps.isEditable === nextProps.isEditable &&
|
||||||
prevProps.vspOptions === nextProps.vspOptions &&
|
prevProps.vspOptions === nextProps.vspOptions &&
|
||||||
prevProps.displayFactor === nextProps.displayFactor &&
|
prevProps.displayFactor === nextProps.displayFactor &&
|
||||||
|
prevProps.moneyUnit === nextProps.moneyUnit &&
|
||||||
prevProps.row.original?.data?.header?.is_over_limit === nextProps.row.original?.data?.header?.is_over_limit
|
prevProps.row.original?.data?.header?.is_over_limit === nextProps.row.original?.data?.header?.is_over_limit
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { nonNegativeColumnKeys } from '../../constants/validation';
|
import { nonNegativeColumnKeys } from '../../constants/validation';
|
||||||
import { useRealtimeActions } from '../../contexts/RealtimeContext';
|
import { getColumnRequestKey, useCellFormula, useRealtimeActions } from '../../contexts/RealtimeContext';
|
||||||
|
import { FORMULA_NEEDS_EQUALS_MESSAGE, looksLikeExpressionWithoutEquals, resolveInitialEditorText } from '../../utils/cellFormulaUtils';
|
||||||
import {
|
import {
|
||||||
calculateFormula,
|
calculateFormula,
|
||||||
extractFormula,
|
extractFormula,
|
||||||
@ -9,9 +10,9 @@ import {
|
|||||||
isFormula,
|
isFormula,
|
||||||
isSameCellValue,
|
isSameCellValue,
|
||||||
normalizeNumericInput,
|
normalizeNumericInput,
|
||||||
resolveEditorState,
|
|
||||||
} from '../../utils/formulaUtils';
|
} from '../../utils/formulaUtils';
|
||||||
import { loadFromStorage, removeFromStorage, saveToStorage } from '../../utils/localStorageUtils';
|
import { isTextColumn } from '../../utils/moneyUtils';
|
||||||
|
import { getRowId } from '../../utils/rowUtils';
|
||||||
|
|
||||||
// Вычисляет отображаемое значение формулы, не бросая исключение наружу.
|
// Вычисляет отображаемое значение формулы, не бросая исключение наружу.
|
||||||
const computeFormulaDisplay = (formulaValue) => {
|
const computeFormulaDisplay = (formulaValue) => {
|
||||||
@ -48,9 +49,10 @@ const TEXTAREA_STYLES = {
|
|||||||
fontSize: 'inherit',
|
fontSize: 'inherit',
|
||||||
};
|
};
|
||||||
|
|
||||||
const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, table, cell }) => {
|
const EditCell = memo(({ refCell, onChange, disabled, value, table, cell }) => {
|
||||||
const { endEditing: contextEndEditing } = useRealtimeActions();
|
const { endEditing: contextEndEditing } = useRealtimeActions();
|
||||||
const storageKey = `formula_v2_${tableId}_${cellId}`;
|
// AURORA-1896: формулу хранит сервер; карта приходит из контекста (localStorage не используется).
|
||||||
|
const formula = useCellFormula(getRowId(cell.row), getColumnRequestKey(cell.column));
|
||||||
|
|
||||||
// AURORA-1891: цифра/Backspace/Delete, нажатые на выделенной (не
|
// AURORA-1891: цифра/Backspace/Delete, нажатые на выделенной (не
|
||||||
// редактируемой пока) ячейке в RealtimeTable.jsx, открывают редактор и
|
// редактируемой пока) ячейке в RealtimeTable.jsx, открывают редактор и
|
||||||
@ -60,26 +62,24 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
|
|||||||
// применяется для getSelectedColumnId/getColumnTitle в этом файле.
|
// применяется для getSelectedColumnId/getColumnTitle в этом файле.
|
||||||
// Инициализатор useState вызывается один раз при монтировании — ровно
|
// Инициализатор useState вызывается один раз при монтировании — ровно
|
||||||
// тогда, когда этот редактор открылся именно так.
|
// тогда, когда этот редактор открылся именно так.
|
||||||
// hasInitialInputRef: редактор открыт набором символа на выделенной ячейке —
|
// Начальный текст: набор символа на выделенной ячейке заменяет значение с нуля
|
||||||
// пользователь заменяет значение с нуля, сохранённую формулу подставлять нельзя.
|
// (формулу не подставляем); иначе актуальная формула открывается как «=выражение»,
|
||||||
const hasInitialInputRef = useRef(false);
|
// неактуальная (значение изменилось без ввода) — просто значение (AURORA-1896).
|
||||||
const [state, setState] = useState(() => {
|
// Инициализатор useState вызывается один раз при монтировании, поэтому набранный
|
||||||
|
// текст не затирается, если формула или значение изменятся, пока ячейка открыта.
|
||||||
|
const [initial] = useState(() => {
|
||||||
const initialInput = table?.options?.meta?.getEditorInitialInput?.();
|
const initialInput = table?.options?.meta?.getEditorInitialInput?.();
|
||||||
if (initialInput !== null && initialInput !== undefined) {
|
if (initialInput !== null && initialInput !== undefined) {
|
||||||
hasInitialInputRef.current = true;
|
|
||||||
return { currentValue: initialInput, displayValue: initialInput, isFormulaMode: false };
|
return { currentValue: initialInput, displayValue: initialInput, isFormulaMode: false };
|
||||||
}
|
}
|
||||||
return { currentValue: value, displayValue: value, isFormulaMode: false };
|
const { text, isFormulaMode } = resolveInitialEditorText(formula, value);
|
||||||
|
return { currentValue: text, displayValue: isFormulaMode ? computeFormulaDisplay(text) : text, isFormulaMode };
|
||||||
});
|
});
|
||||||
|
const [state, setState] = useState(initial);
|
||||||
|
|
||||||
// Исходное значение редактора на момент открытия — чтобы не отправлять
|
// Исходное значение редактора на момент открытия — чтобы не отправлять
|
||||||
// на сервер открытие и закрытие ячейки без правки (AURORA-1895).
|
// на сервер открытие и закрытие ячейки без правки (AURORA-1895).
|
||||||
const initialEditorValueRef = useRef(value);
|
const initialEditorValueRef = useRef(initial.isFormulaMode ? initial.currentValue : value);
|
||||||
// Актуальное значение ячейки для эффекта разрешения формулы: эффект
|
|
||||||
// срабатывает один раз при открытии и не должен затирать набранный текст,
|
|
||||||
// если другой пользователь изменит ячейку, пока она открыта.
|
|
||||||
const valueRef = useRef(value);
|
|
||||||
valueRef.current = value;
|
|
||||||
|
|
||||||
// Позиция ячейки
|
// Позиция ячейки
|
||||||
const [position, setPosition] = useState();
|
const [position, setPosition] = useState();
|
||||||
@ -91,27 +91,6 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
|
|||||||
|
|
||||||
const refFinished = useRef(false);
|
const refFinished = useRef(false);
|
||||||
|
|
||||||
// Загрузка сохраненной формулы (AURORA-1895): подставляем её только если
|
|
||||||
// результат совпадает с текущим значением ячейки, иначе формула устарела
|
|
||||||
// (stale: значение изменил другой пользователь) и удаляется из хранилища.
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasInitialInputRef.current) return;
|
|
||||||
|
|
||||||
const currentCellValue = valueRef.current;
|
|
||||||
const savedFormula = loadFromStorage(storageKey);
|
|
||||||
const { currentValue, isFormulaMode, stale } = resolveEditorState(savedFormula, currentCellValue);
|
|
||||||
|
|
||||||
if (stale) {
|
|
||||||
removeFromStorage(storageKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
initialEditorValueRef.current = currentValue;
|
|
||||||
|
|
||||||
if (isFormulaMode) {
|
|
||||||
setState({ currentValue, displayValue: computeFormulaDisplay(currentValue), isFormulaMode: true });
|
|
||||||
}
|
|
||||||
}, [storageKey]);
|
|
||||||
|
|
||||||
// Вычисление позиции
|
// Вычисление позиции
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!refCell?.current) return;
|
if (!refCell?.current) return;
|
||||||
@ -211,18 +190,24 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formattedResult = formatNumber(result);
|
const formattedResult = formatNumber(result);
|
||||||
saveToStorage(storageKey, currentValue);
|
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
displayValue: formattedResult,
|
displayValue: formattedResult,
|
||||||
isFormulaMode: true,
|
isFormulaMode: true,
|
||||||
}));
|
}));
|
||||||
warnIfNegativeNotAllowed(formattedResult);
|
warnIfNegativeNotAllowed(formattedResult);
|
||||||
onChange?.(formattedResult);
|
// AURORA-1896: уходит значение и выражение без «=» — формулу сохраняет сервер.
|
||||||
|
onChange?.(formattedResult, { formula: extractFormula(currentValue) });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
removeFromStorage(storageKey);
|
// AURORA-1896: «100+50» без «=» — не отправляем (иначе на сервер уйдёт текст), подсказываем
|
||||||
|
// и оставляем редактор открытым. Текстовые колонки не проверяем: там «5-1» — обычный текст.
|
||||||
|
if (!isTextColumn(cell?.column) && looksLikeExpressionWithoutEquals(currentValue)) {
|
||||||
|
toast.info(FORMULA_NEEDS_EQUALS_MESSAGE);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
displayValue: currentValue,
|
displayValue: currentValue,
|
||||||
@ -236,7 +221,7 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
|
|||||||
if (isSameCellValue(normalizedValue, value)) return true;
|
if (isSameCellValue(normalizedValue, value)) return true;
|
||||||
onChange?.(normalizedValue);
|
onChange?.(normalizedValue);
|
||||||
return true;
|
return true;
|
||||||
}, [state, storageKey, onChange, warnIfNegativeNotAllowed, value]);
|
}, [state, cell, onChange, warnIfNegativeNotAllowed, value]);
|
||||||
|
|
||||||
// Завершает редактирование текущей ячейки (cell_edit_end) — читает
|
// Завершает редактирование текущей ячейки (cell_edit_end) — читает
|
||||||
// table.getState().editingCell, а не cell.row/cell.column: к этому моменту
|
// table.getState().editingCell, а не cell.row/cell.column: к этому моменту
|
||||||
|
|||||||
@ -32,7 +32,6 @@ import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/c
|
|||||||
import { isCellEditable } from './utils/cellEditability';
|
import { isCellEditable } from './utils/cellEditability';
|
||||||
import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation';
|
import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation';
|
||||||
import { canAddRowForHeader, getDeleteTarget, getProjectAddTarget, getRenameTarget, isRenameResponse } from './utils/rowUtils';
|
import { canAddRowForHeader, getDeleteTarget, getProjectAddTarget, getRenameTarget, isRenameResponse } from './utils/rowUtils';
|
||||||
import { clearLegacyFormulaKeys } from './utils/localStorageUtils';
|
|
||||||
import { formatHeaderPaths, getValidationRowKey } from './utils/validationUtils';
|
import { formatHeaderPaths, getValidationRowKey } from './utils/validationUtils';
|
||||||
|
|
||||||
import { DictVspApi } from '../../api/dict-vsp';
|
import { DictVspApi } from '../../api/dict-vsp';
|
||||||
@ -269,12 +268,6 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
columnVirtualizerRef.current?.measure?.();
|
columnVirtualizerRef.current?.measure?.();
|
||||||
}, [sizeMult]);
|
}, [sizeMult]);
|
||||||
|
|
||||||
// Разовая очистка устаревших ключей формул, привязанных к позиции строки
|
|
||||||
// (AURORA-1895): они стали недостоверны после перехода на ключи formula_v2_*.
|
|
||||||
useEffect(() => {
|
|
||||||
clearLegacyFormulaKeys();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!formType || !sheetName) return;
|
if (!formType || !sheetName) return;
|
||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
@ -320,8 +313,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
}, [formId, columnsConfig, setVspOptions]);
|
}, [formId, columnsConfig, setVspOptions]);
|
||||||
|
|
||||||
const handleUpdateCell = useCallback(
|
const handleUpdateCell = useCallback(
|
||||||
(row, column, value) => {
|
(row, column, value, options) => {
|
||||||
return contextUpdateCell(row, column, value);
|
return contextUpdateCell(row, column, value, options);
|
||||||
},
|
},
|
||||||
[contextUpdateCell],
|
[contextUpdateCell],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
import { FormsSheetApi } from '../../../api/form_sheet';
|
||||||
|
import { ProjectsApi } from '../../../api/projects';
|
||||||
import { useWebSocket } from '../hooks/useWebSocket';
|
import { useWebSocket } from '../hooks/useWebSocket';
|
||||||
|
import { clearLegacyFormulaStorage, formulaKey } from '../utils/cellFormulaUtils';
|
||||||
|
import { createFormulaStore } from '../utils/formulaStore';
|
||||||
import { buildCellUpdatedMessage } from '../utils/messageUtils';
|
import { buildCellUpdatedMessage } from '../utils/messageUtils';
|
||||||
import { getRowId } from '../utils/rowUtils';
|
import { getRowId } from '../utils/rowUtils';
|
||||||
import { getWsErrorMessage } from '../utils/wsErrorUtils';
|
import { getWsErrorMessage } from '../utils/wsErrorUtils';
|
||||||
@ -9,8 +13,17 @@ const RealtimeActionsContext = createContext(null);
|
|||||||
const RealtimeConnectionContext = createContext(null);
|
const RealtimeConnectionContext = createContext(null);
|
||||||
const RealtimeVspOptionsContext = createContext(null);
|
const RealtimeVspOptionsContext = createContext(null);
|
||||||
const LockedCellsStoreContext = createContext(null);
|
const LockedCellsStoreContext = createContext(null);
|
||||||
|
const FormulaStoreContext = createContext(null);
|
||||||
|
|
||||||
const getColumnRequestKey = (column) => {
|
// AURORA-1896: формулы теперь хранятся на сервере — прежние ключи formula_* в localStorage
|
||||||
|
// удаляем один раз при загрузке модуля (идемпотентно, ошибки хранилища не критичны).
|
||||||
|
try {
|
||||||
|
clearLegacyFormulaStorage(window.localStorage);
|
||||||
|
} catch {
|
||||||
|
// localStorage недоступен (приватный режим и т.п.) — чистить нечего
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getColumnRequestKey = (column) => {
|
||||||
const columnKey = column?.columnDef?.id ?? column?.id;
|
const columnKey = column?.columnDef?.id ?? column?.id;
|
||||||
if (columnKey === null || columnKey === undefined) return '';
|
if (columnKey === null || columnKey === undefined) return '';
|
||||||
|
|
||||||
@ -86,6 +99,19 @@ export const useCellLock = (cellKey) => {
|
|||||||
// именно ЭТА ячейка, без лишнего ре-рендера на каждое изменение чужих блокировок.
|
// именно ЭТА ячейка, без лишнего ре-рендера на каждое изменение чужих блокировок.
|
||||||
export const useLockedCellsStore = () => useRequiredContext(LockedCellsStoreContext, 'useLockedCellsStore');
|
export const useLockedCellsStore = () => useRequiredContext(LockedCellsStoreContext, 'useLockedCellsStore');
|
||||||
|
|
||||||
|
export const useFormulaStore = () => useRequiredContext(FormulaStoreContext, 'useFormulaStore');
|
||||||
|
|
||||||
|
// AURORA-1896: формула ячейки (line_id + ключ колонки без `data.`) или undefined.
|
||||||
|
// Подписка по ключу — при событии перерисовывается только затронутая ячейка.
|
||||||
|
export const useCellFormula = (lineId, columnKey) => {
|
||||||
|
const store = useFormulaStore();
|
||||||
|
const key = formulaKey(lineId, columnKey);
|
||||||
|
const subscribe = useCallback((listener) => store.subscribe(key, listener), [key, store]);
|
||||||
|
const getSnapshot = useCallback(() => store.get(key), [key, store]);
|
||||||
|
|
||||||
|
return useSyncExternalStore(subscribe, getSnapshot, () => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
export const useRealtime = () => {
|
export const useRealtime = () => {
|
||||||
const actions = useRealtimeActions();
|
const actions = useRealtimeActions();
|
||||||
const connection = useRealtimeConnection();
|
const connection = useRealtimeConnection();
|
||||||
@ -110,6 +136,11 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
|
|||||||
lockedCellsStoreRef.current = createLockedCellsStore();
|
lockedCellsStoreRef.current = createLockedCellsStore();
|
||||||
}
|
}
|
||||||
const lockedCellsStore = lockedCellsStoreRef.current;
|
const lockedCellsStore = lockedCellsStoreRef.current;
|
||||||
|
const formulaStoreRef = useRef(null);
|
||||||
|
if (formulaStoreRef.current === null) {
|
||||||
|
formulaStoreRef.current = createFormulaStore();
|
||||||
|
}
|
||||||
|
const formulaStore = formulaStoreRef.current;
|
||||||
|
|
||||||
const handleMessage = useCallback((data) => {
|
const handleMessage = useCallback((data) => {
|
||||||
// Обработка ошибок
|
// Обработка ошибок
|
||||||
@ -141,6 +172,9 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'cell_updated':
|
case 'cell_updated':
|
||||||
|
// AURORA-1896: ответы с ошибкой отсекаются выше (data.error), сюда доходят только
|
||||||
|
// успешные события — и свои, и чужие. Фоновое событие без line_id/column карту не меняет.
|
||||||
|
formulaStoreRef.current.applyEvent(data.data);
|
||||||
if (data.result && onCellUpdateRef.current) {
|
if (data.result && onCellUpdateRef.current) {
|
||||||
const updatedCells = Array.isArray(data.result) ? data.result : [data.result];
|
const updatedCells = Array.isArray(data.result) ? data.result : [data.result];
|
||||||
onCellUpdateRef.current(updatedCells);
|
onCellUpdateRef.current(updatedCells);
|
||||||
@ -197,6 +231,31 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
|
|||||||
|
|
||||||
const { isConnectedRef, isConnected, sendMessage } = useWebSocket(wsUrl, handleMessage);
|
const { isConnectedRef, isConnected, sendMessage } = useWebSocket(wsUrl, handleMessage);
|
||||||
|
|
||||||
|
// AURORA-1896: смена листа/формы/года — карта прежнего листа не должна протекать.
|
||||||
|
// Ответы загрузок, начатых до сброса, стор отбрасывает по токену (reset/beginLoad).
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: идентификаторы листа — триггеры сброса карты
|
||||||
|
useEffect(() => {
|
||||||
|
formulaStore.reset();
|
||||||
|
return () => formulaStore.reset();
|
||||||
|
}, [formulaStore, formId, sheetName, year, isProject]);
|
||||||
|
|
||||||
|
// Карта формул грузится при открытии листа и при каждом (пере)подключении WS:
|
||||||
|
// за время обрыва другие пользователи могли изменить формулы.
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: isConnected — триггер перезагрузки при переподключении
|
||||||
|
useEffect(() => {
|
||||||
|
if (formId === undefined || formId === null || !sheetName) return;
|
||||||
|
const token = formulaStore.beginLoad();
|
||||||
|
const request = isProject ? ProjectsApi.getReportFormulas(formId, year, sheetName) : FormsSheetApi.getFormulas(formId, sheetName);
|
||||||
|
Promise.resolve(request)
|
||||||
|
.then((list) => {
|
||||||
|
formulaStore.finishLoad(token, Array.isArray(list) ? list : []);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn('Не удалось загрузить формулы листа:', error);
|
||||||
|
formulaStore.failLoad(token);
|
||||||
|
});
|
||||||
|
}, [formulaStore, formId, sheetName, year, isProject, isConnected]);
|
||||||
|
|
||||||
const onCellUpdateRef = useRef(null);
|
const onCellUpdateRef = useRef(null);
|
||||||
const onRowAddRef = useRef(null);
|
const onRowAddRef = useRef(null);
|
||||||
const onRowDeleteRef = useRef(null);
|
const onRowDeleteRef = useRef(null);
|
||||||
@ -336,7 +395,7 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
|
|||||||
);
|
);
|
||||||
|
|
||||||
const updateCell = useCallback(
|
const updateCell = useCallback(
|
||||||
(row, column, value) => {
|
(row, column, value, { formula } = {}) => {
|
||||||
if (!isConnectedRef.current) {
|
if (!isConnectedRef.current) {
|
||||||
onErrorRef.current?.('WebSocket не подключен');
|
onErrorRef.current?.('WebSocket не подключен');
|
||||||
return false;
|
return false;
|
||||||
@ -344,7 +403,7 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
|
|||||||
|
|
||||||
const lineId = getRowId(row);
|
const lineId = getRowId(row);
|
||||||
const columnKey = getColumnRequestKey(column);
|
const columnKey = getColumnRequestKey(column);
|
||||||
const message = buildCellUpdatedMessage({ lineId, lineIdCode: row.id, column: columnKey, value });
|
const message = buildCellUpdatedMessage({ lineId, lineIdCode: row.id, column: columnKey, value, formula });
|
||||||
const sent = sendMessage(message);
|
const sent = sendMessage(message);
|
||||||
|
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
@ -638,7 +697,9 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
|
|||||||
<RealtimeActionsContext.Provider value={actions}>
|
<RealtimeActionsContext.Provider value={actions}>
|
||||||
<RealtimeConnectionContext.Provider value={connection}>
|
<RealtimeConnectionContext.Provider value={connection}>
|
||||||
<RealtimeVspOptionsContext.Provider value={vspOptions}>
|
<RealtimeVspOptionsContext.Provider value={vspOptions}>
|
||||||
<LockedCellsStoreContext.Provider value={lockedCellsStore}>{children}</LockedCellsStoreContext.Provider>
|
<LockedCellsStoreContext.Provider value={lockedCellsStore}>
|
||||||
|
<FormulaStoreContext.Provider value={formulaStore}>{children}</FormulaStoreContext.Provider>
|
||||||
|
</LockedCellsStoreContext.Provider>
|
||||||
</RealtimeVspOptionsContext.Provider>
|
</RealtimeVspOptionsContext.Provider>
|
||||||
</RealtimeConnectionContext.Provider>
|
</RealtimeConnectionContext.Provider>
|
||||||
</RealtimeActionsContext.Provider>
|
</RealtimeActionsContext.Provider>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useParams } from 'react-router';
|
|
||||||
import { useVspOptions } from './contexts/RealtimeContext';
|
import { useVspOptions } from './contexts/RealtimeContext';
|
||||||
import { AHR_LIMIT_SHEET, getDisplayFactor, getMoneyUnit } from './utils/moneyUtils';
|
import { AHR_LIMIT_SHEET, getDisplayFactor, getMoneyUnit } from './utils/moneyUtils';
|
||||||
|
|
||||||
@ -11,11 +10,9 @@ import { isCellEditable } from './utils/cellEditability';
|
|||||||
import { getColumnValue } from './utils/columnUtils';
|
import { getColumnValue } from './utils/columnUtils';
|
||||||
|
|
||||||
import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell';
|
import VspDropdownEditCell from './Cell/EditCell/VspDropdownEditCell';
|
||||||
import { getForm4TypeLabel, getRowId } from './utils/rowUtils';
|
import { getForm4TypeLabel } from './utils/rowUtils';
|
||||||
|
|
||||||
const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable }) => {
|
const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable }) => {
|
||||||
const { formId, formType, sheetName, direction } = useParams();
|
|
||||||
const tableKey = `${formId}_${formType}_${sheetName}_${direction}`;
|
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
const [refTbody, setRefTbody] = useState(null);
|
const [refTbody, setRefTbody] = useState(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -28,11 +25,11 @@ const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(val) => {
|
(val, options) => {
|
||||||
table.setEditingCell(null);
|
table.setEditingCell(null);
|
||||||
try {
|
try {
|
||||||
if (table.options.meta?.updateCell) {
|
if (table.options.meta?.updateCell) {
|
||||||
const success = table.options.meta.updateCell(cell.row, cell.column, val);
|
const success = table.options.meta.updateCell(cell.row, cell.column, val, options);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
console.error('Failed to update cell via WebSocket');
|
console.error('Failed to update cell via WebSocket');
|
||||||
onError?.('Не удалось сохранить изменение');
|
onError?.('Не удалось сохранить изменение');
|
||||||
@ -53,13 +50,9 @@ const EditCellPortal = React.memo(({ cell, table, EditCell, onError, isEditable
|
|||||||
value: cell.getValue(),
|
value: cell.getValue(),
|
||||||
disabled: !isEditable,
|
disabled: !isEditable,
|
||||||
onChange: handleChange,
|
onChange: handleChange,
|
||||||
tableId: tableKey,
|
|
||||||
table,
|
table,
|
||||||
// Привязан к line_id из данных строки, а не к её позиции в дереве
|
|
||||||
// (cell.row.id — индекс-путь, который переезжает при вставке/удалении строк).
|
|
||||||
cellId: `${getRowId(cell.row)}_${cell.column.id}`,
|
|
||||||
}),
|
}),
|
||||||
[cell, isEditable, handleChange, tableKey, table],
|
[cell, isEditable, handleChange, table],
|
||||||
);
|
);
|
||||||
|
|
||||||
const editType = cell.column.columnDef?.editType;
|
const editType = cell.column.columnDef?.editType;
|
||||||
@ -182,6 +175,7 @@ export const getTableColumns = ({
|
|||||||
const isEditableCell = isEditable(row, column);
|
const isEditableCell = isEditable(row, column);
|
||||||
const backgroundColor = getColorCell(columnColors, column, row);
|
const backgroundColor = getColorCell(columnColors, column, row);
|
||||||
const displayFactor = getCellDisplayFactor(column);
|
const displayFactor = getCellDisplayFactor(column);
|
||||||
|
const moneyUnit = getMoneyUnit(column, sheetName);
|
||||||
if (!cellPropsCache.has(row)) {
|
if (!cellPropsCache.has(row)) {
|
||||||
cellPropsCache.set(row, {});
|
cellPropsCache.set(row, {});
|
||||||
}
|
}
|
||||||
@ -197,6 +191,7 @@ export const getTableColumns = ({
|
|||||||
cached.columnFilter === currentColumnFilter &&
|
cached.columnFilter === currentColumnFilter &&
|
||||||
cached.value === value &&
|
cached.value === value &&
|
||||||
cached.displayFactor === displayFactor &&
|
cached.displayFactor === displayFactor &&
|
||||||
|
cached.moneyUnit === moneyUnit &&
|
||||||
cached.isInvalid === isInvalid &&
|
cached.isInvalid === isInvalid &&
|
||||||
cached.invalidMessage === invalidMessage &&
|
cached.invalidMessage === invalidMessage &&
|
||||||
cached.backgroundColor === backgroundColor
|
cached.backgroundColor === backgroundColor
|
||||||
@ -217,6 +212,7 @@ export const getTableColumns = ({
|
|||||||
invalidMessage,
|
invalidMessage,
|
||||||
value,
|
value,
|
||||||
displayFactor,
|
displayFactor,
|
||||||
|
moneyUnit,
|
||||||
_hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${invalidMessage}_${backgroundColor}_${displayFactor}`,
|
_hash: `${globalFilter}_${columnFilter}_${key}_${value}_${isInvalid}_${invalidMessage}_${backgroundColor}_${displayFactor}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -49,8 +49,10 @@ const pad = (n) => String(n).padStart(2, '0');
|
|||||||
|
|
||||||
export const formatFormulaHint = (formula) => {
|
export const formatFormulaHint = (formula) => {
|
||||||
const author = formula.updated_by;
|
const author = formula.updated_by;
|
||||||
const name = author?.full_name || `Пользователь #${author?.id}`;
|
const parts = [`=${formula.expression}`];
|
||||||
const parts = [`=${formula.expression}`, name];
|
if (author && (author.full_name || author.id != null)) {
|
||||||
|
parts.push(author.full_name || `Пользователь #${author.id}`);
|
||||||
|
}
|
||||||
if (formula.updated_at) {
|
if (formula.updated_at) {
|
||||||
const d = new Date(formula.updated_at);
|
const d = new Date(formula.updated_at);
|
||||||
if (!Number.isNaN(d.getTime())) {
|
if (!Number.isNaN(d.getTime())) {
|
||||||
@ -60,6 +62,39 @@ export const formatFormulaHint = (formula) => {
|
|||||||
return parts.join(' · ');
|
return parts.join(' · ');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MONEY_UNIT_LABELS = { thousand: 'тыс. руб.', rub: 'руб.' };
|
||||||
|
|
||||||
|
// Подсказка маркера. Выражение хранится в базовых единицах колонки; если ячейка
|
||||||
|
// показана в другой разрядности (displayFactor !== 1), добавляем единицу выражения.
|
||||||
|
export const formatFormulaHintWithUnit = (formula, displayFactor = 1, moneyUnit = null) => {
|
||||||
|
const hint = formatFormulaHint(formula);
|
||||||
|
const label = MONEY_UNIT_LABELS[moneyUnit];
|
||||||
|
return displayFactor !== 1 && label ? `${hint} (в ${label})` : hint;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Начальный текст редактора: «=» + выражение, если формула актуальна для текущего
|
||||||
|
// значения, иначе значение ячейки.
|
||||||
|
export const resolveInitialEditorText = (formula, value) => {
|
||||||
|
if (isFormulaActual(formula, value)) {
|
||||||
|
return { text: `=${formula.expression}`, isFormulaMode: true };
|
||||||
|
}
|
||||||
|
return { text: value, isFormulaMode: false };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FORMULA_NEEDS_EQUALS_MESSAGE = 'Формула начинается с =';
|
||||||
|
|
||||||
|
// Ввод вида «100+50» без ведущего «=»: символы только из набора формулы и после первой
|
||||||
|
// цифры встречается оператор или скобка. Одиночное число (в т.ч. с знаком и запятой) — не выражение.
|
||||||
|
export const looksLikeExpressionWithoutEquals = (text) => {
|
||||||
|
if (typeof text !== 'string') return false;
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed === '' || trimmed.startsWith('=')) return false;
|
||||||
|
if (!/^[0-9+\-*/%(),. \u00a0]+$/.test(trimmed)) return false;
|
||||||
|
const firstDigit = trimmed.search(/\d/);
|
||||||
|
if (firstDigit === -1) return false;
|
||||||
|
return /[+\-*/%()]/.test(trimmed.slice(firstDigit + 1));
|
||||||
|
};
|
||||||
|
|
||||||
export const clearLegacyFormulaStorage = (storage) => {
|
export const clearLegacyFormulaStorage = (storage) => {
|
||||||
try {
|
try {
|
||||||
const toRemove = [];
|
const toRemove = [];
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
|
FORMULA_NEEDS_EQUALS_MESSAGE,
|
||||||
applyFormulaEvent,
|
applyFormulaEvent,
|
||||||
buildFormulaMap,
|
buildFormulaMap,
|
||||||
clearLegacyFormulaStorage,
|
clearLegacyFormulaStorage,
|
||||||
formatFormulaHint,
|
formatFormulaHint,
|
||||||
|
formatFormulaHintWithUnit,
|
||||||
formulaKey,
|
formulaKey,
|
||||||
isFormulaActual,
|
isFormulaActual,
|
||||||
|
looksLikeExpressionWithoutEquals,
|
||||||
|
resolveInitialEditorText,
|
||||||
} from './cellFormulaUtils.js';
|
} from './cellFormulaUtils.js';
|
||||||
|
|
||||||
const user = { id: 7, full_name: 'Иванов И.И.' };
|
const user = { id: 7, full_name: 'Иванов И.И.' };
|
||||||
@ -108,3 +112,50 @@ test('clearLegacyFormulaStorage', () => {
|
|||||||
assert.equal(clearLegacyFormulaStorage(broken), 0);
|
assert.equal(clearLegacyFormulaStorage(broken), 0);
|
||||||
assert.equal(clearLegacyFormulaStorage(null), 0);
|
assert.equal(clearLegacyFormulaStorage(null), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('formatFormulaHint: без автора часть автора опускается (T6.1)', () => {
|
||||||
|
assert.equal(formatFormulaHint({ expression: '1+2', updated_at: '2026-11-11T10:05:00' }), '=1+2 · 11.11.2026 10:05');
|
||||||
|
assert.equal(formatFormulaHint({ expression: '1+2', updated_by: {}, updated_at: null }), '=1+2');
|
||||||
|
assert.ok(!formatFormulaHint({ expression: '1+2', updated_by: {} }).includes('undefined'));
|
||||||
|
assert.equal(formatFormulaHint({ expression: '1+2', updated_by: { id: 0 } }), '=1+2 · Пользователь #0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatFormulaHint: некорректная updated_at — без даты и без NaN (T6.2)', () => {
|
||||||
|
const hint = formatFormulaHint({ expression: '1+2', updated_by: user, updated_at: 'не дата' });
|
||||||
|
assert.equal(hint, '=1+2 · Иванов И.И.');
|
||||||
|
assert.ok(!hint.includes('NaN'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyFormulaEvent: line_id 0 и column "" не считаются отсутствующими (T6.2)', () => {
|
||||||
|
const next = applyFormulaEvent(new Map(), { line_id: 0, column: 'a', value: 5, formula: '2+3' });
|
||||||
|
assert.equal(next.get('0|a').expression, '2+3');
|
||||||
|
const next2 = applyFormulaEvent(new Map(), { line_id: 3, column: '', value: 5, formula: '2+3' });
|
||||||
|
assert.equal(next2.get('3|').expression, '2+3');
|
||||||
|
assert.equal(applyFormulaEvent(next, { line_id: 0, column: 'a', value: 5, formula: null }).size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatFormulaHintWithUnit добавляет единицу при displayFactor !== 1', () => {
|
||||||
|
const f = { expression: '1+2', updated_by: user };
|
||||||
|
assert.equal(formatFormulaHintWithUnit(f, 1, 'thousand'), '=1+2 · Иванов И.И.');
|
||||||
|
assert.equal(formatFormulaHintWithUnit(f, 0.001, 'thousand'), '=1+2 · Иванов И.И. (в тыс. руб.)');
|
||||||
|
assert.equal(formatFormulaHintWithUnit(f, 1000, 'rub'), '=1+2 · Иванов И.И. (в руб.)');
|
||||||
|
assert.equal(formatFormulaHintWithUnit(f, 1000, null), '=1+2 · Иванов И.И.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveInitialEditorText: актуальная формула -> "=" + выражение', () => {
|
||||||
|
const f = { expression: '100+50', value: 150 };
|
||||||
|
assert.deepEqual(resolveInitialEditorText(f, 150), { text: '=100+50', isFormulaMode: true });
|
||||||
|
assert.deepEqual(resolveInitialEditorText(f, '150'), { text: '=100+50', isFormulaMode: true });
|
||||||
|
assert.deepEqual(resolveInitialEditorText(f, 200), { text: 200, isFormulaMode: false });
|
||||||
|
assert.deepEqual(resolveInitialEditorText(undefined, 150), { text: 150, isFormulaMode: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('looksLikeExpressionWithoutEquals', () => {
|
||||||
|
for (const s of ['100+50', ' 10*2 ', '(1+2)*3', '100/4', '50%', '1,5+2', '10 - 3']) {
|
||||||
|
assert.equal(looksLikeExpressionWithoutEquals(s), true, s);
|
||||||
|
}
|
||||||
|
for (const s of ['150', '-5', '+5', '1 500,5', '', '=1+2', 'abc', 'Договор 5-1', '01.11-05.11 ab', null, 12, '-', '1.5', ' 12,5 ']) {
|
||||||
|
assert.equal(looksLikeExpressionWithoutEquals(s), false, String(s));
|
||||||
|
}
|
||||||
|
assert.equal(FORMULA_NEEDS_EQUALS_MESSAGE, 'Формула начинается с =');
|
||||||
|
});
|
||||||
|
|||||||
66
web/src/components/RealtimeTable/utils/formulaStore.js
Normal file
66
web/src/components/RealtimeTable/utils/formulaStore.js
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
// AURORA-1896: стор карты формул листа (без React). Подписка по ключу ячейки,
|
||||||
|
// защита от устаревших ответов загрузки и повтор WS-событий, пришедших во время загрузки.
|
||||||
|
import { applyFormulaEvent, buildFormulaMap } from './cellFormulaUtils.js';
|
||||||
|
|
||||||
|
export const createFormulaStore = () => {
|
||||||
|
let map = new Map();
|
||||||
|
let loadSeq = 0;
|
||||||
|
let pending = null;
|
||||||
|
const listenersByKey = new Map();
|
||||||
|
|
||||||
|
const notify = (prev, next) => {
|
||||||
|
const keys = new Set([...prev.keys(), ...next.keys()]);
|
||||||
|
for (const key of keys) {
|
||||||
|
if (prev.get(key) !== next.get(key)) {
|
||||||
|
listenersByKey.get(key)?.forEach((listener) => listener());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const replace = (next) => {
|
||||||
|
const prev = map;
|
||||||
|
map = next;
|
||||||
|
notify(prev, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
get: (key) => map.get(key),
|
||||||
|
size: () => map.size,
|
||||||
|
subscribe: (key, listener) => {
|
||||||
|
const listeners = listenersByKey.get(key) || new Set();
|
||||||
|
listeners.add(listener);
|
||||||
|
listenersByKey.set(key, listeners);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
if (listeners.size === 0) listenersByKey.delete(key);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
// Смена листа/формы: карта очищается, ответы прежних загрузок игнорируются.
|
||||||
|
reset: () => {
|
||||||
|
loadSeq += 1;
|
||||||
|
pending = null;
|
||||||
|
replace(new Map());
|
||||||
|
},
|
||||||
|
beginLoad: () => {
|
||||||
|
loadSeq += 1;
|
||||||
|
pending = [];
|
||||||
|
return loadSeq;
|
||||||
|
},
|
||||||
|
finishLoad: (token, list) => {
|
||||||
|
if (token !== loadSeq) return false;
|
||||||
|
let next = buildFormulaMap(list);
|
||||||
|
for (const data of pending ?? []) next = applyFormulaEvent(next, data);
|
||||||
|
pending = null;
|
||||||
|
replace(next);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
failLoad: (token) => {
|
||||||
|
if (token === loadSeq) pending = null;
|
||||||
|
},
|
||||||
|
applyEvent: (data) => {
|
||||||
|
if (pending) pending.push(data);
|
||||||
|
const next = applyFormulaEvent(map, data);
|
||||||
|
if (next !== map) replace(next);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
66
web/src/components/RealtimeTable/utils/formulaStore.test.js
Normal file
66
web/src/components/RealtimeTable/utils/formulaStore.test.js
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createFormulaStore } from './formulaStore.js';
|
||||||
|
|
||||||
|
const item = (line_id, column, expression, value) => ({ line_id, column, expression, value });
|
||||||
|
|
||||||
|
test('finishLoad заполняет карту и уведомляет подписчика ключа', () => {
|
||||||
|
const store = createFormulaStore();
|
||||||
|
let calls = 0;
|
||||||
|
store.subscribe('1|a', () => calls++);
|
||||||
|
const token = store.beginLoad();
|
||||||
|
assert.equal(store.finishLoad(token, [item(1, 'a', '1+2', 3), item(2, 'b', '5*2', 10)]), true);
|
||||||
|
assert.equal(store.get('1|a').expression, '1+2');
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('устаревший ответ после reset или новой загрузки игнорируется', () => {
|
||||||
|
const store = createFormulaStore();
|
||||||
|
const old = store.beginLoad();
|
||||||
|
store.reset();
|
||||||
|
assert.equal(store.finishLoad(old, [item(1, 'a', '1+2', 3)]), false);
|
||||||
|
assert.equal(store.size(), 0);
|
||||||
|
|
||||||
|
const first = store.beginLoad();
|
||||||
|
const second = store.beginLoad();
|
||||||
|
assert.equal(store.finishLoad(first, [item(1, 'a', 'old', 1)]), false);
|
||||||
|
assert.equal(store.finishLoad(second, [item(1, 'a', 'new', 1)]), true);
|
||||||
|
assert.equal(store.get('1|a').expression, 'new');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset очищает карту и уведомляет подписчиков', () => {
|
||||||
|
const store = createFormulaStore();
|
||||||
|
store.finishLoad(store.beginLoad(), [item(1, 'a', '1+2', 3)]);
|
||||||
|
let calls = 0;
|
||||||
|
store.subscribe('1|a', () => calls++);
|
||||||
|
store.reset();
|
||||||
|
assert.equal(store.get('1|a'), undefined);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('WS-событие во время загрузки повторяется поверх ответа', () => {
|
||||||
|
const store = createFormulaStore();
|
||||||
|
const token = store.beginLoad();
|
||||||
|
store.applyEvent({ line_id: 1, column: 'a', value: 7, formula: '3+4', formula_meta: null });
|
||||||
|
store.applyEvent({ line_id: 2, column: 'b', value: 1, formula: null, formula_meta: null });
|
||||||
|
store.finishLoad(token, [item(1, 'a', 'stale', 1), item(2, 'b', '9', 9)]);
|
||||||
|
assert.equal(store.get('1|a').expression, '3+4');
|
||||||
|
assert.equal(store.get('2|b'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyEvent: фоновое событие без line_id карту не меняет и не уведомляет', () => {
|
||||||
|
const store = createFormulaStore();
|
||||||
|
store.finishLoad(store.beginLoad(), [item(1, 'a', '1+2', 3)]);
|
||||||
|
let calls = 0;
|
||||||
|
store.subscribe('1|a', () => calls++);
|
||||||
|
store.applyEvent({ source_sheet: 'X' });
|
||||||
|
assert.equal(store.size(), 1);
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failLoad не стирает карту', () => {
|
||||||
|
const store = createFormulaStore();
|
||||||
|
store.finishLoad(store.beginLoad(), [item(1, 'a', '1+2', 3)]);
|
||||||
|
store.failLoad(store.beginLoad());
|
||||||
|
assert.equal(store.get('1|a').expression, '1+2');
|
||||||
|
});
|
||||||
@ -86,7 +86,7 @@ export const evaluateCellValue = (value) => {
|
|||||||
// запись в БД и в журнале аудита при открытии/закрытии ячейки без правки).
|
// запись в БД и в журнале аудита при открытии/закрытии ячейки без правки).
|
||||||
// Формула — особый случай: даже текстуально совпадающая формула всегда
|
// Формула — особый случай: даже текстуально совпадающая формула всегда
|
||||||
// считается «изменённой», чтобы handleSave по-прежнему всегда её
|
// считается «изменённой», чтобы handleSave по-прежнему всегда её
|
||||||
// пересчитывал и писал в localStorage (так было до этой задачи).
|
// пересчитывал и отправлял выражение на сервер (AURORA-1896).
|
||||||
const normalizeComparableValue = (value) => {
|
const normalizeComparableValue = (value) => {
|
||||||
if (value === null || value === undefined) return '';
|
if (value === null || value === undefined) return '';
|
||||||
if (typeof value === 'number') return String(value);
|
if (typeof value === 'number') return String(value);
|
||||||
@ -123,37 +123,6 @@ export const isSameCellValue = (a, b) => {
|
|||||||
return normalizeComparableValue(a) === normalizeComparableValue(b);
|
return normalizeComparableValue(a) === normalizeComparableValue(b);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Начальное состояние редактора ячейки.
|
|
||||||
* Решает, показывать ли сохранённую формулу или текущее значение ячейки,
|
|
||||||
* и помечает формулу как устаревшую (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 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Форматирует число для отображения
|
* Форматирует число для отображения
|
||||||
* @param {number} value - Число для форматирования
|
* @param {number} value - Число для форматирования
|
||||||
|
|||||||
@ -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 { isSameCellValue, resolveEditorState } from './formulaUtils.js';
|
import { isSameCellValue } from './formulaUtils.js';
|
||||||
|
|
||||||
// AURORA-1891: handleSave (EditCell.jsx) не должен слать cell_updated, если
|
// AURORA-1891: handleSave (EditCell.jsx) не должен слать cell_updated, если
|
||||||
// значение фактически не изменилось — иначе лишняя запись в БД/аудите при
|
// значение фактически не изменилось — иначе лишняя запись в БД/аудите при
|
||||||
@ -37,38 +37,6 @@ test('non-numeric text compares as-is after trimming surrounding whitespace', ()
|
|||||||
assert.equal(isSameCellValue(' текст ', 'текст'), true);
|
assert.equal(isSameCellValue(' текст ', 'текст'), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// AURORA-1895: формула из localStorage не перезаписывает чужое значение.
|
|
||||||
|
|
||||||
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', () => {
|
test('isSameCellValue treats a numeric string and a number as equal', () => {
|
||||||
assert.equal(isSameCellValue('150', 150), true);
|
assert.equal(isSameCellValue('150', 150), true);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -81,23 +81,6 @@ 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
|
* Очистка всех данных приложения из localStorage
|
||||||
* @param {string[]} keysToKeep - Массив ключей, которые нужно сохранить
|
* @param {string[]} keysToKeep - Массив ключей, которые нужно сохранить
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
// value всегда присутствует: сервер читает event_data["value"], AURORA-1848 D5.2
|
// value всегда присутствует: сервер читает event_data["value"], AURORA-1848 D5.2
|
||||||
export const buildCellUpdatedMessage = ({ lineId, lineIdCode, column, value }) => ({
|
// formula (AURORA-1896) — выражение без «=»; поле data.formula добавляется только когда оно передано.
|
||||||
|
export const buildCellUpdatedMessage = ({ lineId, lineIdCode, column, value, formula }) => ({
|
||||||
event: 'cell_updated',
|
event: 'cell_updated',
|
||||||
data: {
|
data: {
|
||||||
line_id: lineId,
|
line_id: lineId,
|
||||||
line_id_code: lineIdCode,
|
line_id_code: lineIdCode,
|
||||||
column,
|
column,
|
||||||
value: value === undefined ? null : value,
|
value: value === undefined ? null : value,
|
||||||
|
...(typeof formula === 'string' && formula !== '' ? { formula } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -15,3 +15,13 @@ test('buildCellUpdatedMessage: прочие значения не меняютс
|
|||||||
assert.equal(msg(v).data.value, v);
|
assert.equal(msg(v).data.value, v);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildCellUpdatedMessage: formula кладётся в data.formula только когда передана (AURORA-1896)', () => {
|
||||||
|
const withFormula = buildCellUpdatedMessage({ lineId: 1, lineIdCode: '0.1', column: 'q1.m1', value: 150, formula: '100+50' });
|
||||||
|
assert.equal(withFormula.data.formula, '100+50');
|
||||||
|
assert.equal(Object.hasOwn(msg(150).data, 'formula'), false);
|
||||||
|
for (const f of [undefined, null, '']) {
|
||||||
|
const m = buildCellUpdatedMessage({ lineId: 1, lineIdCode: '0.1', column: 'q1.m1', value: 1, formula: f });
|
||||||
|
assert.equal(Object.hasOwn(m.data, 'formula'), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user