fix(realtime): красная рамка у ошибки ячейки, тост без Server error:undefined (AURORA-1894)

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>
This commit is contained in:
Andrey Epifancev 2026-11-01 12:10:03 +04:00
parent 9229ee5ce3
commit 83cb364a9e
4 changed files with 92 additions and 3 deletions

View File

@ -181,9 +181,9 @@ const CellComponent = ({ cell, row, column, onClick, globalFilter, columnFilter,
textAlign: isNumeric ? 'right' : 'left',
backgroundColor: isLocked ? '#f5f5f5' : backgroundColor,
color: textColor,
...(isEditable ? EDITABLE_STYLES : NOT_EDITABLE_STYLES),
...(isLocked ? LOCKED_STYLES : {}),
...(isInvalid ? INVALID_STYLES : {}),
...(isEditable ? EDITABLE_STYLES : NOT_EDITABLE_STYLES),
}),
[backgroundColor, isInvalid, isLocked, textColor, isEditable, isNumeric, nameIndent],
);

View File

@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
import { toast } from 'react-toastify';
import { useWebSocket } from '../hooks/useWebSocket';
import { getRowId } from '../utils/rowUtils';
import { getWsErrorMessage } from '../utils/wsErrorUtils';
const RealtimeActionsContext = createContext(null);
const RealtimeConnectionContext = createContext(null);
@ -105,8 +106,8 @@ export const RealtimeProvider = ({ children, formId, sheetName, direction, year,
const handleMessage = useCallback((data) => {
// Обработка ошибок
if (data.error) {
console.log(data.error.message);
toast.error(`Server error:${data.error.message}` || '');
console.log(data.error);
toast.error(getWsErrorMessage(data.error));
onErrorRef.current?.(data.error);
return;
}

View File

@ -0,0 +1,35 @@
// Человеческие тексты для технических префиксов ошибок, которые шлёт backend
// (api/src/core/CONSTANTS.py: VALIDATION_PREFIXES, PG_403_ERRORS, и org_not_assigned из прав доступа).
const KNOWN_PREFIX_MESSAGES = [
{ prefix: 'computed_field:', message: 'Это расчётная ячейка, её нельзя изменить' },
{ prefix: 'role_not_allowed:', message: 'Нет прав на изменение этой колонки' },
{ prefix: 'window_closed:', message: 'Окно ввода для этой колонки закрыто' },
{ prefix: 'org_not_assigned', message: 'Вы не назначены на эту форму' },
];
const DEFAULT_ERROR_MESSAGE = 'Неизвестная ошибка сервера';
// Если текст начинается с известного технического префикса — возвращает человеческую замену, иначе null.
const resolveKnownPrefix = (text) => {
const normalized = String(text ?? '').trim();
if (!normalized) return null;
const lower = normalized.toLowerCase();
const match = KNOWN_PREFIX_MESSAGES.find(({ prefix }) => lower.startsWith(prefix));
return match ? match.message : null;
};
// Приводит ошибку WebSocket (строка или объект {code, field, message}/{detail}) к читаемому тексту для тоста.
// Никогда не возвращает undefined/null и не добавляет технический префикс "Server error:".
export const getWsErrorMessage = (error) => {
if (typeof error === 'string') {
return resolveKnownPrefix(error) ?? (error.trim() || DEFAULT_ERROR_MESSAGE);
}
if (error && typeof error === 'object') {
const raw = error.message || error.detail;
if (!raw) return DEFAULT_ERROR_MESSAGE;
return resolveKnownPrefix(raw) ?? raw;
}
return DEFAULT_ERROR_MESSAGE;
};

View File

@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { getWsErrorMessage } from './wsErrorUtils.js';
test('строка возвращается как есть', () => {
assert.equal(getWsErrorMessage('Ячейка уже редактируется другим пользователем'), 'Ячейка уже редактируется другим пользователем');
});
test('объект с message возвращает message', () => {
assert.equal(getWsErrorMessage({ code: 400, field: 'q1.transfer_econ', message: 'Нельзя указать отрицательное значение' }), 'Нельзя указать отрицательное значение');
});
test('объект без message возвращает detail', () => {
assert.equal(getWsErrorMessage({ detail: 'Ошибка подключения к базе данных' }), 'Ошибка подключения к базе данных');
});
test('объект без message и без detail возвращает дефолтный текст, а не undefined', () => {
const result = getWsErrorMessage({ code: 500 });
assert.notEqual(result, undefined);
assert.equal(typeof result, 'string');
assert.ok(!result.includes('undefined'));
});
test('префикс computed_field: заменяется человеческим текстом', () => {
assert.equal(getWsErrorMessage('computed_field: q1.corrected_plan'), 'Это расчётная ячейка, её нельзя изменить');
assert.equal(getWsErrorMessage({ message: 'computed_field: q1.corrected_plan' }), 'Это расчётная ячейка, её нельзя изменить');
});
test('префикс role_not_allowed: заменяется человеческим текстом', () => {
assert.equal(getWsErrorMessage('role_not_allowed: data.q1.transfer_econ'), 'Нет прав на изменение этой колонки');
});
test('префикс window_closed: заменяется человеческим текстом', () => {
assert.equal(getWsErrorMessage('window_closed: data.q1.transfer_econ'), 'Окно ввода для этой колонки закрыто');
});
test('префикс org_not_assigned заменяется человеческим текстом', () => {
assert.equal(getWsErrorMessage('org_not_assigned'), 'Вы не назначены на эту форму');
assert.equal(getWsErrorMessage({ message: 'org_not_assigned: form_id=123' }), 'Вы не назначены на эту форму');
});
test('неизвестный текст возвращается без технического префикса Server error:', () => {
const result = getWsErrorMessage('Database unavailable');
assert.equal(result, 'Database unavailable');
assert.ok(!result.startsWith('Server error:'));
});
test('пустая строка и отсутствующая ошибка не превращаются в undefined', () => {
assert.notEqual(getWsErrorMessage(''), undefined);
assert.notEqual(getWsErrorMessage(null), undefined);
assert.notEqual(getWsErrorMessage(undefined), undefined);
assert.ok(!String(getWsErrorMessage(undefined)).includes('undefined'));
});