AURORA-1891: редактирование одним кликом и с клавиатуры, без лишних записей в аудит #290
@ -152,7 +152,6 @@ const CellComponent = ({
|
||||
cell,
|
||||
row,
|
||||
column,
|
||||
onClick,
|
||||
globalFilter,
|
||||
columnFilter,
|
||||
backgroundColor,
|
||||
@ -236,22 +235,12 @@ const CellComponent = ({
|
||||
return <div style={LOCK_BADGE_STYLES}>🔒</div>;
|
||||
}, [isLocked]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!isLocked && isEditable && onClick) {
|
||||
onClick();
|
||||
}
|
||||
}, [isLocked, isEditable, onClick]);
|
||||
|
||||
// AURORA-1891: клик теперь обрабатывает muiTableBodyCellProps (RealtimeTable.jsx)
|
||||
// — там же проверяется редактируемость/блокировка и открывается редактор одним
|
||||
// кликом; onClick/handleClick здесь были мёртвым кодом (tableColumns.jsx никогда
|
||||
// не передавал onClick в Cell).
|
||||
const cellNode = (
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
|
||||
<div
|
||||
type='button'
|
||||
className='cell'
|
||||
style={cellStyles}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className='cell' style={cellStyles} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||
<span style={CONTAINER_STYLES}>{highlightedContent}</span>
|
||||
{lockBadge}
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,7 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat
|
||||
import { toast } from 'react-toastify';
|
||||
import { nonNegativeColumnKeys } from '../../constants/validation';
|
||||
import { useRealtimeActions } from '../../contexts/RealtimeContext';
|
||||
import { calculateFormula, extractFormula, formatNumber, isFormula, normalizeNumericInput } from '../../utils/formulaUtils';
|
||||
import { calculateFormula, extractFormula, formatNumber, isFormula, isSameCellValue, normalizeNumericInput } from '../../utils/formulaUtils';
|
||||
import { loadFromStorage, saveToStorage } from '../../utils/localStorageUtils';
|
||||
|
||||
const EDITOR_STYLES = {
|
||||
@ -35,15 +35,29 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
|
||||
const { endEditing: contextEndEditing } = useRealtimeActions();
|
||||
const storageKey = `formula_${tableId}_${cellId}`;
|
||||
|
||||
const [state, setState] = useState(() => ({
|
||||
currentValue: value,
|
||||
displayValue: value,
|
||||
isFormulaMode: false,
|
||||
}));
|
||||
// AURORA-1891: цифра/Backspace/Delete, нажатые на выделенной (не
|
||||
// редактируемой пока) ячейке в RealtimeTable.jsx, открывают редактор и
|
||||
// подставляют этот символ (или пустую строку) вместо текущего значения —
|
||||
// RealtimeTable хранит его в своём state и отдаёт через
|
||||
// table.options.meta.getEditorInitialInput(), тот же приём, что уже
|
||||
// применяется для getSelectedColumnId/getColumnTitle в этом файле.
|
||||
// Инициализатор useState вызывается один раз при монтировании — ровно
|
||||
// тогда, когда этот редактор открылся именно так.
|
||||
const [state, setState] = useState(() => {
|
||||
const initialInput = table?.options?.meta?.getEditorInitialInput?.();
|
||||
if (initialInput !== null && initialInput !== undefined) {
|
||||
return { currentValue: initialInput, displayValue: initialInput, isFormulaMode: false };
|
||||
}
|
||||
return { currentValue: value, displayValue: value, isFormulaMode: false };
|
||||
});
|
||||
|
||||
// Позиция ячейки
|
||||
const [position, setPosition] = useState();
|
||||
const textareaRef = useRef(null);
|
||||
// AURORA-1891: <td> этой ячейки — чтобы после закрытия редактора вернуть
|
||||
// туда фокус (иначе стрелки/клавиатурная навигация отваливаются после
|
||||
// первого же редактирования, см. muiTableBodyCellProps.onKeyDown).
|
||||
const tdRef = useRef(null);
|
||||
|
||||
const refFinished = useRef(false);
|
||||
|
||||
@ -77,6 +91,7 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
|
||||
const tr = td?.offsetParent;
|
||||
|
||||
if (td && tr) {
|
||||
tdRef.current = td;
|
||||
setPosition({
|
||||
left: td.offsetLeft,
|
||||
width: td.offsetWidth,
|
||||
@ -85,6 +100,15 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
|
||||
}
|
||||
}, [refCell]);
|
||||
|
||||
// AURORA-1891: при закрытии редактора (размонтирование) фокус возвращается
|
||||
// в <td> этой ячейки — без этого фокус терялся бы на удалённом textarea,
|
||||
// и стрелки/Tab/Enter перестают работать сразу после первого редактирования.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
tdRef.current?.focus?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-resize
|
||||
useLayoutEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
@ -105,15 +129,18 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
|
||||
}, [state.currentValue]);
|
||||
|
||||
useEffect(() => {
|
||||
// Небольшая задержка для гарантии, что DOM полностью готов
|
||||
const timeoutId = setTimeout(() => {
|
||||
// AURORA-1891: setTimeout(50) был произвольной задержкой "на всякий
|
||||
// случай"; requestAnimationFrame ждёт ровно до следующего кадра
|
||||
// отрисовки — DOM (textarea) к этому моменту уже готов, фокус приходит
|
||||
// на кадр раньше и без угадывания длительности.
|
||||
const frameId = requestAnimationFrame(() => {
|
||||
if (textareaRef.current && !disabled) {
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.select();
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}, [disabled]);
|
||||
|
||||
// AURORA-1892: мягкий запрет минуса для заданных колонок — предупреждаем,
|
||||
@ -163,9 +190,27 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
|
||||
}));
|
||||
const normalizedValue = normalizeNumericInput(currentValue);
|
||||
warnIfNegativeNotAllowed(normalizedValue);
|
||||
// AURORA-1891: значение не изменилось — не шлём onChange (а значит и
|
||||
// cell_updated): открытие и закрытие ячейки без правки не должно
|
||||
// создавать запись в журнале аудита.
|
||||
if (isSameCellValue(normalizedValue, value)) return;
|
||||
onChange?.(normalizedValue);
|
||||
}
|
||||
}, [state, storageKey, onChange, warnIfNegativeNotAllowed]);
|
||||
}, [state, storageKey, onChange, warnIfNegativeNotAllowed, value]);
|
||||
|
||||
// Завершает редактирование текущей ячейки (cell_edit_end) — читает
|
||||
// table.getState().editingCell, а не cell.row/cell.column: к этому моменту
|
||||
// handleSave (через onChange -> EditCellPortal.handleChange) уже мог
|
||||
// вызвать table.setEditingCell(null), но реальным состоянием владеет
|
||||
// RealtimeTable.jsx (контролируемый state.editingCell), так что
|
||||
// table.getState().editingCell всё ещё возвращает ячейку, которую мы
|
||||
// только что редактировали.
|
||||
const endCurrentEditing = useCallback(() => {
|
||||
const editingCell = table.getState().editingCell;
|
||||
if (editingCell) {
|
||||
contextEndEditing?.(editingCell.row, editingCell.column);
|
||||
}
|
||||
}, [table, contextEndEditing]);
|
||||
|
||||
const finishEditing = useCallback(() => {
|
||||
if (refFinished.current) return;
|
||||
@ -173,11 +218,16 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
|
||||
if (!disabled) {
|
||||
handleSave();
|
||||
}
|
||||
const editingCell = table.getState().editingCell;
|
||||
if (editingCell) {
|
||||
contextEndEditing?.(editingCell.row, editingCell.column);
|
||||
}
|
||||
}, [disabled, handleSave, table, contextEndEditing]);
|
||||
endCurrentEditing();
|
||||
}, [disabled, handleSave, endCurrentEditing]);
|
||||
|
||||
// AURORA-1891: Esc — отмена, handleSave вообще не вызывается (никакого
|
||||
// cell_updated), только завершение редактирования текущей ячейки.
|
||||
const cancelEditing = useCallback(() => {
|
||||
if (refFinished.current) return;
|
||||
refFinished.current = true;
|
||||
endCurrentEditing();
|
||||
}, [endCurrentEditing]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
@ -201,12 +251,36 @@ const EditCell = memo(({ refCell, onChange, disabled, value, tableId, cellId, ta
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e) => {
|
||||
if (refFinished.current) return;
|
||||
|
||||
// AURORA-1891: отмена — значение не сохраняется, cell_updated не шлётся.
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelEditing();
|
||||
return;
|
||||
}
|
||||
|
||||
// AURORA-1891: Tab/Shift+Tab — сохранить и перейти к следующей/предыдущей
|
||||
// редактируемой ячейке в строке. Переход резолвит и прокручивает
|
||||
// table.options.meta.openNextEditableCell (RealtimeTable.jsx); сама
|
||||
// ячейка открывается позже, в эффекте, который ждёт cell_edit_end
|
||||
// текущей (см. ruling в PR) — здесь мы только помечаем цель и сохраняем.
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
table.options.meta?.openNextEditableCell?.(cell.row, cell.column, e.shiftKey ? 'left' : 'right');
|
||||
finishEditing();
|
||||
return;
|
||||
}
|
||||
|
||||
// Shift+Enter — перенос строки в textarea (стандартное поведение,
|
||||
// e.preventDefault() не вызывается).
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
table.options.meta?.openNextEditableCell?.(cell.row, cell.column, 'down');
|
||||
finishEditing();
|
||||
}
|
||||
},
|
||||
[finishEditing],
|
||||
[cancelEditing, finishEditing, table, cell],
|
||||
);
|
||||
|
||||
const editorStyles = useMemo(
|
||||
|
||||
@ -8,7 +8,7 @@ const EDITOR_STYLES = {
|
||||
top: 0,
|
||||
};
|
||||
|
||||
const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table }) => {
|
||||
const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table, cell }) => {
|
||||
const { endEditing: contextEndEditing } = useRealtimeActions();
|
||||
const vspOptions = useVspOptions();
|
||||
|
||||
@ -17,6 +17,9 @@ const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table })
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const refFinished = useRef(false);
|
||||
// AURORA-1891: см. тот же приём в EditCell.jsx — фокус после закрытия
|
||||
// редактора возвращается в <td>, чтобы клавиатурная навигация не отваливалась.
|
||||
const tdRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
@ -32,6 +35,7 @@ const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table })
|
||||
const tr = td?.offsetParent;
|
||||
|
||||
if (td && tr) {
|
||||
tdRef.current = td;
|
||||
setPosition({
|
||||
left: td.offsetLeft,
|
||||
width: td.offsetWidth,
|
||||
@ -40,6 +44,12 @@ const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table })
|
||||
}
|
||||
}, [refCell]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
tdRef.current?.focus?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const finishEditing = useCallback(() => {
|
||||
if (refFinished.current) return;
|
||||
refFinished.current = true;
|
||||
@ -50,6 +60,32 @@ const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table })
|
||||
}
|
||||
}, [table, contextEndEditing]);
|
||||
|
||||
// AURORA-1891: та же клавиатурная логика, что в EditCell.jsx — Esc уже
|
||||
// работает через onClose MUI Select (reason !== 'selectOption' вызывает
|
||||
// finishEditing без onChange, т.е. без cell_updated — отмена). Здесь
|
||||
// добираем Tab/Shift+Tab и Enter: сохранить (для dropdown «сохранить» —
|
||||
// это просто закрыть без выбора, onChange уже случился в handleSelectChange,
|
||||
// если пользователь кликнул опцию) и перейти к следующей редактируемой ячейке.
|
||||
const handleKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (refFinished.current) return;
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
table.options.meta?.openNextEditableCell?.(cell.row, cell.column, event.shiftKey ? 'left' : 'right');
|
||||
finishEditing();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
table.options.meta?.openNextEditableCell?.(cell.row, cell.column, 'down');
|
||||
finishEditing();
|
||||
}
|
||||
},
|
||||
[cell, finishEditing, table],
|
||||
);
|
||||
|
||||
const handleSelectChange = useCallback(
|
||||
(e) => {
|
||||
const val = e.target.value;
|
||||
@ -92,6 +128,7 @@ const VspDropdownEditCell = memo(({ refCell, onChange, disabled, value, table })
|
||||
<Select
|
||||
value={selectedValue}
|
||||
onChange={handleSelectChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
open={open}
|
||||
onClose={handleSelectClose}
|
||||
displayEmpty
|
||||
|
||||
@ -18,7 +18,7 @@ import { SelectVspModal } from './Modals/SelectVspModal';
|
||||
import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig';
|
||||
import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, getTableBodyCellProps, getTablePaperStyles, getTableRowHeightPx } from './constants/tableConfig';
|
||||
import { createFixedRowVirtualizerOptions } from './utils/virtualizerOptions';
|
||||
import { useRealtimeActions, useVspOptions } from './contexts/RealtimeContext';
|
||||
import { useLockedCellsStore, useRealtimeActions, useVspOptions } from './contexts/RealtimeContext';
|
||||
import { useColumnSettings } from './hooks/useColumnSettings';
|
||||
import { useHeaderPortal } from './hooks/useHeaderPortal';
|
||||
import { useTableScale } from './hooks/useTableScale';
|
||||
@ -26,6 +26,8 @@ import { useBudgetYear } from './hooks/useBudgetYear';
|
||||
import { useValidationRules } from './hooks/useValidationRules';
|
||||
import { getRowId } from './utils/rowUtils';
|
||||
import { getColumnVirtualizerOptions, getOrderedVisibleColumns } from './utils/columnLayoutUtils';
|
||||
import { isCellEditable } from './utils/cellEditability';
|
||||
import { findNextEditableColumnId, findNextEditableRowId } from './utils/cellNavigation';
|
||||
|
||||
import { DictVspApi } from '../../api/dict-vsp';
|
||||
import { useAuth } from '../../app/context/AuthProvider';
|
||||
@ -97,6 +99,20 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
rowSelectionRef.current = rowSelection;
|
||||
const [, startTransition] = useTransition();
|
||||
const [editingCell, setEditingCell] = useState(null);
|
||||
// AURORA-1891: цифра/Backspace/Delete, нажатые на выделенной ячейке,
|
||||
// открывают редактор и подставляют этот символ (или '') вместо текущего
|
||||
// значения — EditCell читает его через table.options.meta.getEditorInitialInput
|
||||
// (ref-обёртка ниже — тот же приём, что у selectedColumnIdRef, чтобь не
|
||||
// пересоздавать tableMeta на каждое нажатие клавиши).
|
||||
const [editorInitialInput, setEditorInitialInput] = useState(null);
|
||||
const editorInitialInputRef = useRef(editorInitialInput);
|
||||
editorInitialInputRef.current = editorInitialInput;
|
||||
// AURORA-1891: «отложенная цель» для Tab/Enter — следующая ячейка
|
||||
// открывается не сразу (сервер ещё не закончил cell_edit_end текущей), а
|
||||
// в эффекте ниже, когда dataEditingCells (и вместе с ним editingCell)
|
||||
// станет null. См. ruling в PR про порядок cell_updated -> cell_edit_end
|
||||
// -> cell_edit_start следующей.
|
||||
const pendingEditTargetRef = useRef(null);
|
||||
const [columnsConfig, setColumnsConfig] = useState(null);
|
||||
const { errors: validationErrors, getCellErrors, headerPaths } = useValidationRules(data, columnsConfig?.columns, formType, sheetName);
|
||||
|
||||
@ -143,6 +159,45 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
addProject: contextAddProject,
|
||||
setVspOptions,
|
||||
} = useRealtimeActions();
|
||||
// AURORA-1891: нужен сам стор (не подписка на конкретную ячейку, как
|
||||
// useCellLock) — для имперактивной проверки «не заблокирована ли эта
|
||||
// ячейка» внутри onClick/onKeyDown на td (muiTableBodyCellProps ниже).
|
||||
const lockedCellsStore = useLockedCellsStore();
|
||||
|
||||
const isAdminUser = userRoleId === ROLES_NAME_ID.admin;
|
||||
const stageColumnsSet = useMemo(() => new Set(columnsCurStage), [columnsCurStage]);
|
||||
|
||||
// AURORA-1891: та же проверка, что isCellEditable/isEditable в
|
||||
// tableColumns.jsx, нужна здесь отдельно — у клика/клавиатуры на ячейке
|
||||
// (и у Tab/Enter перехода) нет доступа к замыканию processColumns.
|
||||
const cellIsEditable = useCallback(
|
||||
(row, columnId) =>
|
||||
isCellEditable({
|
||||
rowType: row.original?.row_type,
|
||||
columnId,
|
||||
isAdmin: isAdminUser,
|
||||
stageColumns: stageColumnsSet,
|
||||
}),
|
||||
[isAdminUser, stageColumnsSet],
|
||||
);
|
||||
|
||||
// AURORA-1891: cellIsEditable(row, columnId) не знает про сам column.def —
|
||||
// для номера строки (accessorKey 'sort_order') enableEditing жёстко false
|
||||
// (tableColumns.jsx), а cellIsEditable для INPUT-строки у админа сказала
|
||||
// бы true. Без этой сверки клик/Tab у админа мог бы попытаться открыть
|
||||
// (несуществующий) редактор для номера строки вместо выделения строки
|
||||
// или перехода дальше. Используется и в muiTableBodyCellProps, и в
|
||||
// openNextEditableCell (Tab/Enter не должны переходить на такую колонку).
|
||||
const isColumnActuallyEditable = useCallback(
|
||||
(row, column) => {
|
||||
if (!cellIsEditable(row, column.id)) return false;
|
||||
const enableEditing = column.columnDef?.enableEditing;
|
||||
if (typeof enableEditing === 'function') return enableEditing(row);
|
||||
if (typeof enableEditing === 'boolean') return enableEditing;
|
||||
return true;
|
||||
},
|
||||
[cellIsEditable],
|
||||
);
|
||||
|
||||
const {
|
||||
columnSizing,
|
||||
@ -297,12 +352,23 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
const selectedColumnIdRef = useRef(selectedColumnId);
|
||||
selectedColumnIdRef.current = selectedColumnId;
|
||||
|
||||
// AURORA-1891: сама функция объявляется ниже (после создания table — ей
|
||||
// нужны getOrderedVisibleColumns(table)/table.getRowModel().flatRows), а
|
||||
// tableMeta собирается до table. Тот же приём, что у selectedColumnIdRef:
|
||||
// стабильная обёртка в tableMeta, реальная реализация — в ref, который
|
||||
// обновляется на каждом рендере ниже.
|
||||
const openNextEditableCellRef = useRef(null);
|
||||
|
||||
const tableMeta = useMemo(
|
||||
() => ({
|
||||
updateCell: handleUpdateCell,
|
||||
getSelectedColumnId: () => selectedColumnIdRef.current,
|
||||
// AURORA-1892: человекочитаемый заголовок колонки для предупреждения о запрете минуса.
|
||||
getColumnTitle: (columnId) => headerPaths[columnId]?.filter(Boolean).join(' / ') || columnId,
|
||||
// AURORA-1891: «начальный ввод» для EditCell при открытии редактора цифрой/Backspace/Delete.
|
||||
getEditorInitialInput: () => editorInitialInputRef.current,
|
||||
// AURORA-1891: Tab/Shift+Tab/Enter из EditCell.jsx/VspDropdownEditCell.jsx.
|
||||
openNextEditableCell: (row, column, direction) => openNextEditableCellRef.current?.(row, column, direction),
|
||||
}),
|
||||
[handleUpdateCell, headerPaths],
|
||||
);
|
||||
@ -343,6 +409,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
contextStartEditing,
|
||||
globalFilterFn,
|
||||
activeSearchMatch,
|
||||
isColumnActuallyEditable,
|
||||
lockedCellsStore,
|
||||
setEditorInitialInput,
|
||||
}) => ({
|
||||
...BASE_TABLE_CONFIG,
|
||||
globalFilterFn,
|
||||
@ -354,7 +423,10 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
columnVirtualizerInstanceRef: columnVirtualizerRef,
|
||||
rowVirtualizerOptions: ROW_VIRTUALIZER_OPTIONS,
|
||||
onEditingCellChange: (cell) => {
|
||||
if (cell && !editingCell) {
|
||||
// AURORA-1891: защита на случай, если что-то кроме нашего собственного
|
||||
// onClick/onKeyDown откроет редактор для нередактируемой ячейки —
|
||||
// cell_edit_start на сервер не уйдёт, блокировка (🔒 у коллег) не возьмётся.
|
||||
if (cell && !editingCell && isColumnActuallyEditable(cell.row, cell.column)) {
|
||||
contextStartEditing?.(cell.row, cell.column);
|
||||
}
|
||||
},
|
||||
@ -398,11 +470,54 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
sx: { boxSizing: 'border-box' },
|
||||
},
|
||||
muiTableBodyCellProps: (props) => {
|
||||
const { cell, column, row, table } = props;
|
||||
const cellProps = getTableBodyCellProps(props);
|
||||
const isActiveMatch = activeSearchMatch?.rowId === props.row.id && activeSearchMatch?.columnId === props.column.id;
|
||||
const isActiveMatch = activeSearchMatch?.rowId === row.id && activeSearchMatch?.columnId === column.id;
|
||||
const cellKey = `${row.id}_${column.id}`;
|
||||
|
||||
// AURORA-1891: открыть редактор можно только если ячейка редактируема
|
||||
// (isColumnActuallyEditable — см. определение выше про колонку
|
||||
// номера строки), не занята коллегой (lockedCellsStore) и сейчас
|
||||
// вообще ничего не редактируется. Иначе клик/клавиша не трогают td —
|
||||
// клик всплывает на строку (её onClick выделяет строку — п.7
|
||||
// тикета), клавиша просто игнорируется.
|
||||
const canOpenEditor = () => !editingCell && isColumnActuallyEditable(row, column) && !lockedCellsStore.hasCell(cellKey);
|
||||
|
||||
return {
|
||||
...cellProps,
|
||||
onClick: (event) => {
|
||||
if (!canOpenEditor()) return;
|
||||
// Не даём клику дойти до muiTableBodyRowProps.onClick — иначе
|
||||
// открытие редактора попутно выделило/сняло бы выделение строки.
|
||||
event.stopPropagation();
|
||||
table.setEditingCell(cell);
|
||||
},
|
||||
onKeyDown: (event) => {
|
||||
if (!canOpenEditor()) return;
|
||||
|
||||
// Enter/F2 — открыть редактор с текущим значением.
|
||||
if (event.key === 'Enter' || event.key === 'F2') {
|
||||
event.preventDefault();
|
||||
table.setEditingCell(cell);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete/Backspace — открыть редактор с пустым значением.
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
event.preventDefault();
|
||||
setEditorInitialInput('');
|
||||
table.setEditingCell(cell);
|
||||
return;
|
||||
}
|
||||
|
||||
// Печатный символ без модификаторов — «начальный ввод»: открыть
|
||||
// редактор и подставить его вместо текущего значения.
|
||||
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
event.preventDefault();
|
||||
setEditorInitialInput(event.key);
|
||||
table.setEditingCell(cell);
|
||||
}
|
||||
},
|
||||
sx: {
|
||||
...cellProps.sx,
|
||||
...(isActiveMatch && {
|
||||
@ -464,6 +579,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
contextStartEditing,
|
||||
globalFilterFn,
|
||||
activeSearchMatch,
|
||||
isColumnActuallyEditable,
|
||||
lockedCellsStore,
|
||||
setEditorInitialInput,
|
||||
}),
|
||||
[
|
||||
columns,
|
||||
@ -482,6 +600,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
expanded,
|
||||
globalFilterFn,
|
||||
activeSearchMatch,
|
||||
isColumnActuallyEditable,
|
||||
lockedCellsStore,
|
||||
setEditorInitialInput,
|
||||
],
|
||||
);
|
||||
|
||||
@ -518,6 +639,60 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
[columnPinning, containerRef, table],
|
||||
);
|
||||
|
||||
// AURORA-1891: Tab/Shift+Tab (direction 'right'/'left', в строке) и Enter
|
||||
// ('down', по столбцу) — вызывается из EditCell.jsx/VspDropdownEditCell.jsx
|
||||
// через table.options.meta.openNextEditableCell. Сам обход уже готового
|
||||
// списка — в чистых findNextEditableColumnId/findNextEditableRowId
|
||||
// (utils/cellNavigation.js); здесь — только резолв реальных MRT
|
||||
// row/column (flatRows, не .rows — см. рулинг AURORA-1893 выше в
|
||||
// handleNavigateToError) и прокрутка к цели, по образцу navigateToSearchMatch.
|
||||
//
|
||||
// Открытие целевой ячейки происходит не здесь: сервер должен сначала
|
||||
// закончить cell_edit_end текущей, поэтому мы только помечаем цель в
|
||||
// pendingEditTargetRef — реальный table.setEditingCell для неё вызывается
|
||||
// в эффекте ниже, когда dataEditingCells (а с ним editingCell) станет null.
|
||||
const openNextEditableCell = useCallback(
|
||||
(row, column, direction) => {
|
||||
let targetRowId = row.id;
|
||||
let targetColumnId = column.id;
|
||||
|
||||
if (direction === 'right' || direction === 'left') {
|
||||
const columns = getOrderedVisibleColumns(table);
|
||||
const nextColumnId = findNextEditableColumnId({
|
||||
columns,
|
||||
currentColumnId: column.id,
|
||||
step: direction === 'right' ? 1 : -1,
|
||||
isEditable: (candidateColumn) => isColumnActuallyEditable(row, candidateColumn),
|
||||
});
|
||||
if (!nextColumnId) return;
|
||||
targetColumnId = nextColumnId;
|
||||
} else if (direction === 'down' || direction === 'up') {
|
||||
const rows = table.getRowModel().flatRows;
|
||||
const nextRowId = findNextEditableRowId({
|
||||
rows,
|
||||
currentRowId: row.id,
|
||||
step: direction === 'down' ? 1 : -1,
|
||||
isEditable: (candidateRow) => isColumnActuallyEditable(candidateRow, column),
|
||||
});
|
||||
if (!nextRowId) return;
|
||||
targetRowId = nextRowId;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingEditTargetRef.current = { rowId: targetRowId, columnId: targetColumnId };
|
||||
|
||||
handleNavigateToColumn(targetColumnId);
|
||||
requestAnimationFrame(() => {
|
||||
const rowIndex = table.getRowModel().rows.findIndex((visibleRow) => visibleRow.id === targetRowId);
|
||||
if (rowIndex < 0) return;
|
||||
rowVirtualizerRef.current?.scrollToIndex?.(rowIndex < 5 ? rowIndex : rowIndex + 5, { align: 'auto', behavior: 'smooth' });
|
||||
});
|
||||
},
|
||||
[table, isColumnActuallyEditable, handleNavigateToColumn],
|
||||
);
|
||||
openNextEditableCellRef.current = openNextEditableCell;
|
||||
|
||||
// AURORA-1893: переход от плашки «Контроль ошибок» к конкретной ячейке (строка + колонка),
|
||||
// а не только к колонке, как раньше. Строим по образцу navigateToSearchMatch ниже.
|
||||
//
|
||||
@ -643,6 +818,27 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
useEffect(() => {
|
||||
if (!dataEditingCells?.line_id) {
|
||||
setEditingCell(null);
|
||||
|
||||
// AURORA-1891: Tab/Enter из EditCell.jsx пометили следующую ячейку в
|
||||
// pendingEditTargetRef (openNextEditableCell выше) — сервер только
|
||||
// что закончил cell_edit_end текущей (editingCell/dataEditingCells
|
||||
// стали null), теперь можно открыть следующую, тем же путём, что и
|
||||
// обычное открытие по клику/double-click (table.setEditingCell
|
||||
// триггерит onEditingCellChange -> contextStartEditing -> cell_edit_start).
|
||||
const pendingTarget = pendingEditTargetRef.current;
|
||||
if (pendingTarget) {
|
||||
pendingEditTargetRef.current = null;
|
||||
const animationFrameId = requestAnimationFrame(() => {
|
||||
try {
|
||||
const row = table.getRow(pendingTarget.rowId);
|
||||
const cell = row?.getVisibleCells().find((c) => c.column.id === pendingTarget.columnId);
|
||||
if (cell) table.setEditingCell(cell);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
return () => cancelAnimationFrame(animationFrameId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (editingCell) return;
|
||||
@ -660,6 +856,17 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
return () => cancelAnimationFrame(animationFrameId);
|
||||
}, [dataEditingCells, editingCell, table]);
|
||||
|
||||
// AURORA-1891: «начальный ввод» (цифра/Backspace/Delete) нужен только для
|
||||
// самого первого монтирования EditCell этой ячейки (useState-инициализатор
|
||||
// там читает его один раз) — как только редактор открылся, очищаем, чтобы
|
||||
// следующее открытие той же ячейки другим способом не подхватило старое
|
||||
// значение.
|
||||
useEffect(() => {
|
||||
if (editingCell && editorInitialInputRef.current !== null) {
|
||||
setEditorInitialInput(null);
|
||||
}
|
||||
}, [editingCell]);
|
||||
|
||||
const getSelectedRow = useCallback(() => table.getRowModel().rows.find((row) => rowSelectionRef.current[row.id]), [table]);
|
||||
|
||||
const handleAddRow = useCallback(() => {
|
||||
|
||||
@ -78,6 +78,13 @@ export const useCellLock = (cellKey) => {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, () => false);
|
||||
};
|
||||
|
||||
// AURORA-1891: имперактивная проверка lockedCellsStore.hasCell(key) из
|
||||
// обработчика клика/клавиатуры на ячейке (muiTableBodyCellProps в
|
||||
// RealtimeTable.jsx) — там нужен сам стор (не подписка на конкретную ячейку,
|
||||
// как useCellLock выше), чтобы в момент события синхронно узнать, занята ли
|
||||
// именно ЭТА ячейка, без лишнего ре-рендера на каждое изменение чужих блокировок.
|
||||
export const useLockedCellsStore = () => useRequiredContext(LockedCellsStoreContext, 'useLockedCellsStore');
|
||||
|
||||
export const useRealtime = () => {
|
||||
const actions = useRealtimeActions();
|
||||
const connection = useRealtimeConnection();
|
||||
|
||||
@ -134,10 +134,16 @@ export const getTableColumns = ({
|
||||
// пересборку колонок, а не на каждую ячейку.
|
||||
const stageColumns = new Set(columnsCurStage);
|
||||
|
||||
const isEditable = (row, column) =>
|
||||
// AURORA-1891: раньше принимала объект колонки (column.id); теперь — голый
|
||||
// columnId, чтобы один и тот же вызов годился и для настоящих MRT-колонок
|
||||
// (getCachedCellProps/getCachedEditProps, column.id), и для листовых
|
||||
// колонок конфига внутри processColumns, у которых своего column.id ещё
|
||||
// нет — есть только col.accessorKey (то же самое значение после того, как
|
||||
// MRT построит из него колонку).
|
||||
const isEditable = (row, columnId) =>
|
||||
isCellEditable({
|
||||
rowType: row.original?.row_type,
|
||||
columnId: column.id,
|
||||
columnId,
|
||||
isAdmin: userRoleId === ROLES_NAME_ID.admin,
|
||||
stageColumns,
|
||||
});
|
||||
@ -150,7 +156,7 @@ export const getTableColumns = ({
|
||||
const cellErrors = getCellErrors?.(column.id, value, row.original) || [];
|
||||
const isInvalid = cellErrors.length > 0;
|
||||
const invalidMessage = cellErrors.join('. ');
|
||||
const isEditableCell = isEditable(row, column);
|
||||
const isEditableCell = isEditable(row, column.id);
|
||||
const backgroundColor = getColorCell(columnColors, column, row);
|
||||
if (!cellPropsCache.has(row)) {
|
||||
cellPropsCache.set(row, {});
|
||||
@ -207,7 +213,7 @@ export const getTableColumns = ({
|
||||
}
|
||||
|
||||
const props = {
|
||||
isEditable: isEditable(row, column),
|
||||
isEditable: isEditable(row, column.id),
|
||||
};
|
||||
|
||||
rowCache[key] = props;
|
||||
@ -219,6 +225,9 @@ export const getTableColumns = ({
|
||||
for (const col of columns) {
|
||||
col.header = normalizeMonthHeader(col.header);
|
||||
const usesVspOptions = col.editType === 'vsp_dropdown';
|
||||
// Группирующие колонки-заголовки (есть col.columns) не рендерят Cell/Edit
|
||||
// по строкам сами — enableEditing им не нужен, в отличие от листовых.
|
||||
const isLeafColumn = !col.columns?.length;
|
||||
|
||||
col.Cell = ({ cell, table, column, row }) => {
|
||||
const props = getCachedCellProps(row, column, table);
|
||||
@ -247,8 +256,15 @@ export const getTableColumns = ({
|
||||
return editComponent;
|
||||
};
|
||||
|
||||
if (col.editType === 'vsp_dropdown') {
|
||||
col.enableEditing = (row) => row?.original?.row_type === 'INPUT';
|
||||
// AURORA-1891: раньше enableEditing выставлялся только для editType
|
||||
// 'vsp_dropdown' (частный случай) — для остальных листовых колонок MRT
|
||||
// считал строку INPUT редактируемой всегда, даже вычисляемые поля и
|
||||
// колонки вне текущего этапа; от этого двойной клик открывал редактор
|
||||
// (пусть и disabled) и сервер всё равно брал блокировку (🔒 у коллег).
|
||||
// Теперь единая проверка isCellEditable для каждой листовой колонки —
|
||||
// MRT не откроет редактор ни по двойному клику, ни программно.
|
||||
if (isLeafColumn) {
|
||||
col.enableEditing = (row) => isEditable(row, col.accessorKey);
|
||||
}
|
||||
|
||||
if (col.columns?.length) {
|
||||
|
||||
46
web/src/components/RealtimeTable/utils/cellNavigation.js
Normal file
46
web/src/components/RealtimeTable/utils/cellNavigation.js
Normal file
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* AURORA-1891: Tab/Shift+Tab и Enter в EditCell.jsx/VspDropdownEditCell.jsx
|
||||
* вызывают table.options.meta.openNextEditableCell(row, column, direction)
|
||||
* (RealtimeTable.jsx), который резолвит реальные MRT-строки/колонки
|
||||
* (getOrderedVisibleColumns, table.getRowModel().flatRows — не .rows, см.
|
||||
* рулинг AURORA-1893 про expandRows()/paginateExpandedRows: .rows уже
|
||||
* отфильтрован состоянием свёрнутости групп, и строка в свёрнутой группе
|
||||
* там попросту не нашлась бы) и предикат isCellEditable, а эти две функции
|
||||
* делают только сам обход уже готового списка — без знания про MRT.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Следующая (step: 1, Tab) или предыдущая (step: -1, Shift+Tab) редактируемая
|
||||
* колонка в той же строке. columns — видимые листовые колонки в порядке
|
||||
* getOrderedVisibleColumns (скрытые туда не попадают — для этой функции они
|
||||
* просто не существуют). isEditable(column) — редактируема ли эта колонка
|
||||
* ИМЕННО в текущей строке (вызывающая сторона уже знает строку).
|
||||
* Возвращает id следующей редактируемой колонки или null, если дошли до края.
|
||||
*/
|
||||
export const findNextEditableColumnId = ({ columns, currentColumnId, step, isEditable }) => {
|
||||
const startIndex = columns.findIndex((column) => column.id === currentColumnId);
|
||||
if (startIndex < 0) return null;
|
||||
|
||||
for (let index = startIndex + step; index >= 0 && index < columns.length; index += step) {
|
||||
if (isEditable(columns[index])) return columns[index].id;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Следующая (step: 1, Enter) или предыдущая (step: -1) строка, где заданная
|
||||
* колонка редактируема. rows — table.getRowModel().flatRows (весь порядок
|
||||
* строк независимо от текущего состояния свёрнутости групп). isEditable(row)
|
||||
* — редактируема ли заданная колонка в этой строке (пропускает итоговые
|
||||
* строки ROOT/GROUP/ITEM, у которых row_type !== 'INPUT').
|
||||
* Возвращает id следующей строки или null, если дошли до края таблицы.
|
||||
*/
|
||||
export const findNextEditableRowId = ({ rows, currentRowId, step, isEditable }) => {
|
||||
const startIndex = rows.findIndex((row) => row.id === currentRowId);
|
||||
if (startIndex < 0) return null;
|
||||
|
||||
for (let index = startIndex + step; index >= 0 && index < rows.length; index += step) {
|
||||
if (isEditable(rows[index])) return rows[index].id;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@ -0,0 +1,78 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { findNextEditableColumnId, findNextEditableRowId } from './cellNavigation.js';
|
||||
|
||||
// AURORA-1891: Tab/Shift+Tab (в строке) и Enter (по столбцу, вниз/вверх) —
|
||||
// EditCell.jsx/VspDropdownEditCell.jsx знают только текущую строку/колонку,
|
||||
// а сам обход видимых листовых колонок (getOrderedVisibleColumns) и строк
|
||||
// (table.getRowModel().flatRows) и предикат редактируемости (isCellEditable)
|
||||
// собирает вызывающая сторона (RealtimeTable.jsx meta.openNextEditableCell).
|
||||
// Здесь — только чистый обход уже готового списка.
|
||||
|
||||
// columns — то, что вернул бы getOrderedVisibleColumns: скрытая колонка
|
||||
// конфига сюда вообще не попала бы, поэтому «пропуск скрытых» проверяется
|
||||
// самим фактом, что функция работает только с тем, что ей передали, и про
|
||||
// скрытые колонки ничего не знает.
|
||||
const COLUMNS = [
|
||||
{ id: 'a', editable: true },
|
||||
{ id: 'b', editable: false }, // не редактируется в этой строке (напр. вычисляемая)
|
||||
{ id: 'c', editable: true },
|
||||
{ id: 'd', editable: false },
|
||||
];
|
||||
const isColumnEditable = (column) => column.editable;
|
||||
|
||||
test('Tab: moves right to the next editable column, skipping non-editable ones', () => {
|
||||
const next = findNextEditableColumnId({ columns: COLUMNS, currentColumnId: 'a', step: 1, isEditable: isColumnEditable });
|
||||
assert.equal(next, 'c');
|
||||
});
|
||||
|
||||
test('Shift+Tab: moves left to the previous editable column', () => {
|
||||
const prev = findNextEditableColumnId({ columns: COLUMNS, currentColumnId: 'c', step: -1, isEditable: isColumnEditable });
|
||||
assert.equal(prev, 'a');
|
||||
});
|
||||
|
||||
test('Tab at the end of the row returns null (конец строки)', () => {
|
||||
const next = findNextEditableColumnId({ columns: COLUMNS, currentColumnId: 'c', step: 1, isEditable: isColumnEditable });
|
||||
assert.equal(next, null);
|
||||
});
|
||||
|
||||
test('Shift+Tab at the start of the row returns null', () => {
|
||||
const prev = findNextEditableColumnId({ columns: COLUMNS, currentColumnId: 'a', step: -1, isEditable: isColumnEditable });
|
||||
assert.equal(prev, null);
|
||||
});
|
||||
|
||||
test('an unknown current column id returns null instead of throwing', () => {
|
||||
const next = findNextEditableColumnId({ columns: COLUMNS, currentColumnId: 'missing', step: 1, isEditable: isColumnEditable });
|
||||
assert.equal(next, null);
|
||||
});
|
||||
|
||||
// rows — table.getRowModel().flatRows: включают итоговые строки (GROUP/ITEM/
|
||||
// ROOT), которые isCellEditable делает нередактируемыми независимо от
|
||||
// колонки (rowType !== 'INPUT'); здесь это просто row.editable === false.
|
||||
const ROWS = [
|
||||
{ id: 'row-1', editable: true },
|
||||
{ id: 'row-2', editable: false }, // итоговая строка (GROUP)
|
||||
{ id: 'row-3', editable: true },
|
||||
{ id: 'row-4', editable: false }, // итоговая строка (ROOT)
|
||||
];
|
||||
const isRowEditable = (row) => row.editable;
|
||||
|
||||
test('Enter: moves down to the next row where the column is editable, skipping summary rows', () => {
|
||||
const next = findNextEditableRowId({ rows: ROWS, currentRowId: 'row-1', step: 1, isEditable: isRowEditable });
|
||||
assert.equal(next, 'row-3');
|
||||
});
|
||||
|
||||
test('Enter at the last row returns null (конец таблицы)', () => {
|
||||
const next = findNextEditableRowId({ rows: ROWS, currentRowId: 'row-3', step: 1, isEditable: isRowEditable });
|
||||
assert.equal(next, null);
|
||||
});
|
||||
|
||||
test('moving up (step: -1) finds the previous editable row', () => {
|
||||
const prev = findNextEditableRowId({ rows: ROWS, currentRowId: 'row-3', step: -1, isEditable: isRowEditable });
|
||||
assert.equal(prev, 'row-1');
|
||||
});
|
||||
|
||||
test('an unknown current row id returns null instead of throwing', () => {
|
||||
const next = findNextEditableRowId({ rows: ROWS, currentRowId: 'missing', step: 1, isEditable: isRowEditable });
|
||||
assert.equal(next, null);
|
||||
});
|
||||
@ -81,6 +81,48 @@ export const evaluateCellValue = (value) => {
|
||||
}
|
||||
};
|
||||
|
||||
// AURORA-1891: сравнение "было/стало" для handleSave (EditCell.jsx) — если
|
||||
// значение фактически не изменилось, cell_updated не шлём (иначе лишняя
|
||||
// запись в БД и в журнале аудита при открытии/закрытии ячейки без правки).
|
||||
// Формула — особый случай: даже текстуально совпадающая формула всегда
|
||||
// считается «изменённой», чтобы handleSave по-прежнему всегда её
|
||||
// пересчитывал и писал в localStorage (так было до этой задачи).
|
||||
const normalizeComparableValue = (value) => {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'number') return String(value);
|
||||
if (typeof value !== 'string') return String(value);
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') return '';
|
||||
|
||||
// "1 500,0" -> "1500,0": обычный и неразрывный пробел между цифрами —
|
||||
// разделитель тысяч. normalizeNumericInput сам такие пробелы не убирает,
|
||||
// он только меняет десятичную запятую на точку.
|
||||
const withoutThousandsSeparators = trimmed.replace(/(\d)[ ](?=\d)/g, '$1');
|
||||
const normalized = normalizeNumericInput(withoutThousandsSeparators);
|
||||
|
||||
if (typeof normalized === 'string' && /^[+-]?\d+(\.\d+)?$/.test(normalized)) {
|
||||
const asNumber = Number(normalized);
|
||||
if (Number.isFinite(asNumber)) return String(asNumber);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
/**
|
||||
* Сравнивает старое и новое значение ячейки «по смыслу», а не строго: число
|
||||
* и его строковое представление (в т.ч. с разделителем тысяч и запятой вместо
|
||||
* точки) считаются равными, как и пустая строка с null/undefined. Формула
|
||||
* (строка, начинающаяся с "=") никогда не считается неизменной.
|
||||
* @param {string|number|null|undefined} a
|
||||
* @param {string|number|null|undefined} b
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isSameCellValue = (a, b) => {
|
||||
if (isFormula(a) || isFormula(b)) return false;
|
||||
return normalizeComparableValue(a) === normalizeComparableValue(b);
|
||||
};
|
||||
|
||||
/**
|
||||
* Форматирует число для отображения
|
||||
* @param {number} value - Число для форматирования
|
||||
|
||||
38
web/src/components/RealtimeTable/utils/formulaUtils.test.js
Normal file
38
web/src/components/RealtimeTable/utils/formulaUtils.test.js
Normal file
@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { isSameCellValue } from './formulaUtils.js';
|
||||
|
||||
// AURORA-1891: handleSave (EditCell.jsx) не должен слать cell_updated, если
|
||||
// значение фактически не изменилось — иначе лишняя запись в БД/аудите при
|
||||
// открытии и закрытии ячейки без правки.
|
||||
|
||||
test('a numeric string equals the same number', () => {
|
||||
assert.equal(isSameCellValue('1500', 1500), true);
|
||||
});
|
||||
|
||||
test('a thousands-separated, comma-decimal value equals the plain number', () => {
|
||||
assert.equal(isSameCellValue('1 500,0', 1500), true);
|
||||
});
|
||||
|
||||
test('empty string and null both mean "no value"', () => {
|
||||
assert.equal(isSameCellValue('', null), true);
|
||||
assert.equal(isSameCellValue(null, ''), true);
|
||||
assert.equal(isSameCellValue(undefined, ''), true);
|
||||
assert.equal(isSameCellValue(undefined, null), true);
|
||||
});
|
||||
|
||||
test('a formula is never treated as unchanged, even against identical formula text', () => {
|
||||
assert.equal(isSameCellValue('=1+2', '=1+2'), false);
|
||||
assert.equal(isSameCellValue('=1+1', 2), false);
|
||||
assert.equal(isSameCellValue(2, '=1+1'), false);
|
||||
});
|
||||
|
||||
test('different values (numeric or text) are not the same', () => {
|
||||
assert.equal(isSameCellValue('1500', 1501), false);
|
||||
assert.equal(isSameCellValue('abc', 'abd'), false);
|
||||
assert.equal(isSameCellValue('1500', ''), false);
|
||||
});
|
||||
|
||||
test('non-numeric text compares as-is after trimming surrounding whitespace', () => {
|
||||
assert.equal(isSameCellValue(' текст ', 'текст'), true);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user