AURORA-1891: редактирование одним кликом и с клавиатуры, без лишних записей в аудит #290
Loading…
x
Reference in New Issue
Block a user
No description provided.
Delete Branch "feat/284-aurora-1891-cell-click-keyboard-edit"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #284.
Стек веток (важно для ревью)
Эта ветка стоит не на
test, а поверх четырёх незамерженных PR:test→ PR #277 (AURORA-1894, фикс красной рамки ошибки/тоста) → PR #278 (AURORA-1892, отрицательные суммы красным + nonNegativeRule, merge-коммитd518961) → PR #281 (AURORA-1890, переписанная редактируемая/нередактируемая/закрытая стилистика Cell.jsx черезisCellEditable/isComputedColumn) → PR #283 (AURORA-1893, переписанный пайплайн ошибок валидации иValidationErrorsAlert) → эта ветка.Диф этого PR временно включает коммиты #277, #278, #281 и #283 (они ещё не в
test) — как и каждый из них сам документирует это для своих предшественников. PR создан с--base testпо процессу; после мерджа предыдущих четырёх ревьюеру будет проще смотреть только изменения этой задачи (последний коммит,feat(table): редактирование одним кликом и с клавиатуры...).⚠️ Реальный конфликт с текущим test (не просто стек) — нужно внимание при мердже
В отличие от #277/#278/#281/#283, у этой ветки есть настоящий текстовый конфликт с нынешним концом
test, а не только разница из-за стека:testушёл вперёд прямым коммитом21aded2(«perf(table): статичные стили ячеек в CSS контейнера, overscan строк 2 и колонок 3», 2026-10-28), который убрал динамическуюgetTableBodyCellProps/sxизconstants/tableConfig.jsи переписалmuiTableBodyCellPropsна({ column }) => ({ 'data-col-id': column.id })+ статические CSS-классы (utils/activeCellCss.js,getActiveCellSx). Мой коммит добавляет в тот жеmuiTableBodyCellPropsновыеonClick/onKeyDown(и новый импортuseLockedCellsStoreв соседней строке) — отсюда конфликт вRealtimeTable.jsxна обоих местах.Проверено: ветка
d7ab4d1(всё до этой задачи, т.е. весь стек #277→#278→#281→#283) мержится вorigin/testбез конфликтов (git merge --no-commit --no-ff origin/testпрошёл чисто). Конфликт появляется именно с коммитом AURORA-1891 этого PR. Специально не резолвил его здесь (--no-ffmerge-попытка сделана и abort'нута, рабочее дерево чистое) — совмещать новую логику открытия редактора с чужим CSS-рефактором тайм-кода ячеек требует решения, которое лучше принять при фактическом мердже стека (возможно, уже на другой базе, если #277/#278/#281/#283 к тому моменту сами смержены иtestснова изменится), а не дублировать работу сейчас. Если мерджить будет человек — ему нужно руками перенестиonClick/onKeyDownиз моей версииmuiTableBodyCellPropsв CSS-классовую версию из21aded2.Проблема
Пользователю непонятно, куда можно вводить данные, а где формулы/закрытые поля; переход в ячейку требовал ��вух кликов (двойной клик включал и тут же выключал выделение строки), а двойной клик по нередактируемой ячейке всё равно открывал (disabled) редактор и ставил серверную блокировку — коллеги видели 🔒 на ячейке, в которую нельзя даже писать.
Что сделано
utils/cellEditability.js— без изменений, только переиспользованisCellEditableиз PR #281.tableColumns.jsx(processColumns) —col.enableEditingтеперь выставляется черезisCellEditableдля каждой листовой колонки (раньше — только дляeditType === 'vsp_dropdown', остальные листовые колонки MRT считал редактируемыми всегда, включая вычисляемые поля и колонки вне текущего этапа).isEditable(row, column)внутри файла изменена наisEditable(row, columnId)— голый id, чтобы один вызов годился и для настоящих MRT-колонок, и для листовых колонок конфига (col.accessorKey), у которых своегоcolumn.idещё нет.RealtimeTable.jsx:onEditingCellChange— ранний выход, если ячейка не редактируема (isColumnActuallyEditable), защита на случай обхода нашего собственногоonClick/onKeyDown.muiTableBodyCellProps.onClick— на редактируемой, не заблокированной (lockedCellsStore.hasCell) ячейке, когда ничего не редактируется,stopPropagation()+table.setEditingCell(cell); иначе клик всплывает наmuiTableBodyRowProps.onClickи выделяет строку, как раньше.muiTableBodyCellProps.onKeyDown— на выделенной (сфокусированной) ячейке:Enter/F2открывает редактор;Delete/Backspace— с пустым значением; печатный символ (key.length === 1, без Ctrl/Meta/Alt) — открывает редактор и подставляет этот символ вместо текущего значения. «Начальный ввод» хранится вstate(editorInitialInput) и отдаётся вEditCellчерезtable.options.meta.getEditorInitialInput()— тем же приёмом, что уже используется дляgetSelectedColumnId/getColumnTitle(стабильная обёртка вmeta, актуальное значение — в ref).openNextEditableCell(row, column, direction)(вmeta) — резолвит реальные MRT row/column и зовёт чистыеfindNextEditableColumnId/findNextEditableRowId(utils/cellNavigation.js) поgetOrderedVisibleColumns(table)иtable.getRowModel().flatRows(не.rows— та же причина, что в рулинге PR #283:.rowsотфильтрован состоянием свёрнутости групп черезexpandRows()/paginateExpandedRows, строка в свёрнутой группе там не нашлась бы). Прокручивает к цели (handleNavigateToColumn+rowVirtualizerRef.scrollToIndex, по образцуnavigateToSearchMatch), затем помечает цель вpendingEditTargetRef— реально открывается она позже.dataEditingCells(WScell_edit_start/cell_edit_endдля своей сессии), дополнен: когдаeditingCellсбрасывается вnull(послеcell_edit_endтекущей ячейки) и есть отложенная цель вpendingEditTargetRef, тем же путём (table.setEditingCell→onEditingCellChange→contextStartEditing→ WScell_edit_start) открывается следующая. Порядок WS-событий:cell_updated→cell_edit_end→cell_edit_startследующей — как того требует тикет.Cell/Cell/Cell.jsx— удалён мёртвыйonClick/handleClick(tableColumns.jsx никогда не передавалonClickвCell).Cell/EditCell/EditCell.jsx:handleKeyDown:Escape— отмена,handleSaveне вызывается, только завершение редактирования текущей ячейки (endCurrentEditing, вынесена изfinishEditing);Tab/Shift+Tab— вызываетmeta.openNextEditableCell(..., 'right'|'left')и сохраняет;Enter(без Shift) —meta.openNextEditableCell(..., 'down')и сохраняет;Shift+Enter— как раньше, перенос строки в textarea.handleSave— если новое значение совпадает со старым (isSameCellValue, не формула) —onChangeне вызывается, редактирование просто завершается безcell_updated.setTimeout(50)→requestAnimationFrame; на размонтирование редактора фокус возвращается в<td>этой ячейки (tdRef, захватывается в том же эффекте, что считает позицию), чтобы стрелки/Tab продолжали работать сразу после первого редактирования.useState-инициализатор проверяетtable.options.meta.getEditorInitialInput()один раз при монтировании.Cell/EditCell/VspDropdownEditCell.jsx— та же клавиатурная логика:Escуже работал черезonCloseMUISelect(reason !== 'selectOption'→finishEditing()безonChange); добавленыTab/Shift+Tab/Enter(вызываютmeta.openNextEditableCell) и тот же приём возврата фокуса в<td>на размонтирование. Добавлен пропcell(уже передавался вeditCellPropsизEditCellPortal, но не был в сигнатуре компонента).utils/cellNavigation.js(новый) —findNextEditableColumnId/findNextEditableRowId: чистые функции обхода уже готового списка (видимые листовые колонки /flatRows) с инъекцией предикатаisEditable; ничего не знают про MRT/isCellEditable.utils/formulaUtils.js—isSameCellValue(a, b): число vs строка, нормализация тысячных пробелов и десятичной запятой ("1 500,0"~1500), пусто vsnull/undefined; формула никогда не считается неизменной (даже текстуально идентичная — чтобыhandleSaveпо-прежнему всегда её пересчитывал).Тесты
Новые (TDD, писались до реализации):
node --test src/components/RealtimeTable/utils/cellNavigation.test.js— 9 тестов: следующая/предыдущая редактируемая колонка с пропуском нередактируемых (скрытые колонки просто не передаются в массиве — так же, какgetOrderedVisibleColumnsих не возвращает), следующая/предыдущая строка с пропуском итоговых строк, конец строки/таблицы →null, неизвестный текущий id →null.node --test src/components/RealtimeTable/utils/formulaUtils.test.js— 6 тестов:"1500"vs1500,"1 500,0"vs1500,""/null/undefinedвзаимно равны, формула никогда не «неизменна» (даже против идентичного текста формулы), разные значения не равны, текст сравнивается после trim.Полный прогон
node --test src/components/RealtimeTable/utils/*.test.js— до изменений: 70 тестов, 69 passed, 1 fail (columnLayoutUtils.test.js); после: 85 тестов (+15 новых), 84 passed, тот же единственный pre-existing fail (columnLayoutUtils.test.js, падает из-за отсутствующегоnode_modules/@tanstack/table-coreв этом worktree-окружении, не связан с изменениями этого PR — та же причина уже отмечена в описаниях #278/#281/#283).Ручная проверка — по коду (живой стенд не поднимал, как и предыдущие PR в этой цепочке): один клик по редактируемой ячейке открывает редактор с фокусом на textarea; клик по вычисляемой/закрытой ячейке не открывает редактор и выделяет строку, блокировка не ставится (нет вызова
contextStartEditing, значит нет WScell_edit_start, значит 🔒 у коллег не появляется);Escне вызываетonChange/updateCell;Tab/EnterвызываютopenNextEditableCell, которая прокручивает виртуализатор и контейнер колонок так же, как при переходе к ошибке/результату поиска; печатный символ на выделенной ячейке передаётся вEditCellкак начальное значение; колонка ВСП получила тот же набор клавиш.Критерий приёмки
enableEditingна MRT-колонке + защитная проверка вonEditingCellChange).isSameCellValueпропускаетonChange/cell_updated, когда значение не изменилось).Пункт 8 тикета (backend
websocket.py: не выдавать блокировку, еслиv3.can_editвернётok=false) сознательно не делался — тикет сам выносит его в отдельный PR.Rulings (решения без отдельного обсуждения)
isColumnActuallyEditableвместо гологоcellIsEditable: номер строки (accessorKey: 'sort_order') имеет жёсткийenableEditing: falseвtableColumns.jsx, но независимая проверка черезisCellEditableдля INPUT-строки у администратора вернула быtrue(админ редактирует что угодно на INPUT-строке, если это не вычисляемое поле — аsort_orderне начинается сdata., значит не вычисляемое). Без дополнительной сверки с реальнымcolumn.columnDef.enableEditingклик/Tab у админа могли бы попытаться открыть несуществующий редактор для колонки номера строки вместо выделения строки/перехода дальше. На практике это уже прикрытоstopPropagation()в самой кнопке номера строки, но полагаться только на это было бы хрупко.table.options.meta.getEditorInitialInput(), а не отдельным явным пропом в цепочкеtableColumns.jsx→EditCellPortal→EditCell: тикет говорит «передать пропом в EditCell», но добавление нового поля вgetCachedEditProps/editCellPropsтребовало бы расширять кэш-инвалидацию вtableColumns.jsxна каждое нажатие клавиши. Выбран тот же приём, что уже использует этот файл дляgetSelectedColumnId/getColumnTitle— стабильная функция вmeta, актуальное значение берётся из ref.EditCellвсё равно получает значение через свойtable-проп, технически это «проп», просто не отдельный одноимённый.mousedown-слушатель вEditCell.jsx(click-outside-to-close) закрывает текущую ячейку, ноeditingCellв React-state обновляется только после WS round-trip, поэтому click-обработчик новой ячейки в этот же тик видит старое значениеeditingCellи не открывает её — клик просто выделяет строку, открыть новую ячейку нужно вторым кликом. Это не регрессия (раньше для перехода между ячейками мышью требовался двойной клик на новую ячейку в любом случае) и тикет не требует иного для мыши — только для Tab/Enter, которые обработаны как положено.' ') как «начальный ввод»: тикет формулирует условие буквально какevent.key.length === 1без исключений — пробел подпадает под него, поэтому пробел на выделенной ячейке тоже открывает редактор и подставляет пробел.isSameCellValueи колонка ВСП: сравнение «было/стало» добавлено только вEditCell.jsx(п.5 тикета называет только егоhandleSave).VspDropdownEditCell.handleSelectChangeне трогался — повторный выбор уже выбранного значения в MUISelectвсё равно вызоветonChange/cell_updated(edge-case вне формулировки тикета, пункта про ВСП в п.5 не было).Cell.jsx: порядок спреда стилей — base → editable/not-editable → locked → invalid (INVALID_STYLES последним), чтобы красная рамка 2px не перетиралась EDITABLE_STYLES у вводимых ячеек с ошибкой. utils/wsErrorUtils.js: getWsErrorMessage(error) — строка возвращается как есть, объект берёт message || detail, известные технические префиксы (computed_field:, role_not_allowed:, window_closed:, org_not_assigned) заменяются понятным текстом, прочее отдаётся без "Server error:". RealtimeContext.js: toast.error(getWsErrorMessage(data.error)) вместо `Server error:${data.error.message}` || '', которое всегда давало undefined для ошибок-строк (|| не срабатывает на непустой строке). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>Cell.jsx: formatNumber вынесен в utils/numberFormat.js и возвращает { text, isNegative }; отрицательные числа выделяются цветом (#C62828 на светлом фоне, #FFCDD2 на тёмном) и font-weight 600, ноль остаётся «-». constants/validation.js: фабрика nonNegativeRule и общий список nonNegativeColumnKeys (план, бронь на 2026/2027, факт по месяцам и СПОД, сумма платежа, предельная стоимость договора/ЦКК, сателлиты АХР Аренда/Коммунальные/Безопасность, АХР Лимиты целиком). Корректировки и переносы (adj_*, base_correction, revision_*, seq_*, target_change, transfer_*) не включены — там минус — нормальная операция. Запрет мягкий: подсветка и запись в «Контроль ошибок» через существующий useValidationRules/isCellInvalid, сохранение не блокируется. EditCell.jsx: handleSave предупреждает toast.warning при сохранении отрицательного числа в защищённой колонке, значение всё равно сохраняется. useValidationRules и RealtimeTable прокидывают человекочитаемый заголовок колонки для текста предупреждения. Тесты (node --test): numberFormat.test.js, validationUtils.test.js — новые, 10/10 зелёных.- validationUtils.collectInvalidErrors теперь собирает ошибки по ЯЧЕЙКАМ (id/ruleId/columnId/lineId/rowLabel/message), а не одну запись на колонку; новая groupValidationErrors(errors) группирует их обратно по правилу+колонке со счётчиком для меню плашки. - useValidationRules: getCellErrors(columnId, value, row) отдаёт причины конкретной ячейки (для подсказки), isCellInvalid выражен через него; сравнение стабильности errors — по id (он уже кодирует правило+колонку+строку). - RealtimeTable: handleNavigateToError({lineId, columnId}) — находит строку по getRowId в table.getRowModel().flatRows (не .rows, т.к. flatRows не фильтруется по свёрнутым группам), раскрывает родителей через setExpanded, включает скрытую колонку через handleSetColumnsVisibility, выделяет ячейку как activeSearchMatch и скроллит к ней — по образцу navigateToSearchMatch. - ValidationErrorsAlert: красная плашка (#FDEDED/#D32F2F) со счётчиком «Ошибок: N», кнопками ‹› для прохода по всем ошибкам и меню, сгруппированным по правилу+колонке (до 20 строк, дальше «и ещё N»); перенесена в начало SettingPanel, перед группами настроек. - tableColumns/Cell: invalidMessage (из getCellErrors) доходит до Cell и показывается подсказкой (MUI Tooltip) при наведении на невалидную ячейку. Тесты (node --test .../validationUtils.test.js): 12/12, включая 5 существующих тестов nonNegativeRule из AURORA-1892 (не менялись, подтверждают отсутствие регрессии) и 7 новых для collectInvalidErrors/groupValidationErrors. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>