diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx index 7ada266..b8cb69b 100644 --- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx +++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx @@ -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], ); diff --git a/web/src/components/RealtimeTable/contexts/RealtimeContext.js b/web/src/components/RealtimeTable/contexts/RealtimeContext.js index c56e894..9e52af7 100644 --- a/web/src/components/RealtimeTable/contexts/RealtimeContext.js +++ b/web/src/components/RealtimeTable/contexts/RealtimeContext.js @@ -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; } diff --git a/web/src/components/RealtimeTable/utils/wsErrorUtils.js b/web/src/components/RealtimeTable/utils/wsErrorUtils.js new file mode 100644 index 0000000..37bd677 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/wsErrorUtils.js @@ -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; +}; diff --git a/web/src/components/RealtimeTable/utils/wsErrorUtils.test.js b/web/src/components/RealtimeTable/utils/wsErrorUtils.test.js new file mode 100644 index 0000000..23336e4 --- /dev/null +++ b/web/src/components/RealtimeTable/utils/wsErrorUtils.test.js @@ -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')); +});