diff --git a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx index 041acdc..1478848 100644 --- a/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx +++ b/web/src/components/RealtimeTable/Cell/Cell/Cell.jsx @@ -1,210 +1,91 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { Box, Tooltip, CircularProgress } from '@mui/material'; +import React from 'react'; +import { useMemo } from 'react'; +import styled from '@emotion/styled'; import { useRealtime } from '../../contexts/RealtimeContext'; -const Cell = ({ value, row, column, onSave, userId, isEditable = true }) => { - const [isEditing, setIsEditing] = useState(false); - const [editValue, setEditValue] = useState(value); - const [isSaving, setIsSaving] = useState(false); - const [lockInfo, setLockInfo] = useState(null); - - const { - isCellLocked, - getCellLockInfo, - updateCell, - subscribeToErrors, - tryLockCell, - unlockCell - } = useRealtime(); - - const inputRef = useRef(null); - const rowId = row.original?.id || row.id || row.index; - const columnId = column.id; - // Проверяем блокировку ячейки - useEffect(() => { - if (!isEditing) { - const checkLock = () => { - const lock = getCellLockInfo(rowId, columnId); - setLockInfo(lock); - }; - - checkLock(); - const interval = setInterval(checkLock, 2000); // Проверяем каждые 2 секунды - return () => clearInterval(interval); - } - }, [rowId, columnId, isEditing, getCellLockInfo]); +const ContainerForText = styled.span` + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + white-space: pre-wrap; +`; - // Подписка на ошибки - useEffect(() => { - const unsubscribe = subscribeToErrors((error) => { - console.error('Cell update error:', error); - setIsSaving(false); - setIsEditing(false); - }); - - return unsubscribe; - }, [subscribeToErrors]); +// Функция для подсветки текста +const highlightText = (text, searchQuery) => { + if (!searchQuery || !text) return text; - const isLocked = isCellLocked(rowId, columnId); - const isLockedByMe = lockInfo?.userId === userId; + // Экранируем спецсимволы в поисковом запросе + const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(${escapedQuery})`, 'gi'); - const handleDoubleClick = useCallback(() => { - if (!isEditable) return; - - if (!isLocked || isLockedByMe) { - // Пытаемся заблокировать ячейку - if (tryLockCell(rowId, columnId, userId)) { - setIsEditing(true); - setEditValue(value); - setTimeout(() => inputRef.current?.focus(), 100); - } else { - const lock = getCellLockInfo(rowId, columnId); - console.warn(`Cell is locked by user ${lock?.userId}`); - } - } - }, [isEditable, isLocked, isLockedByMe, tryLockCell, rowId, columnId, userId, value, getCellLockInfo]); + const parts = text.split(regex); - const handleSave = useCallback(async () => { - if (editValue === value) { - setIsEditing(false); - unlockCell(rowId, columnId, userId); - return; - } - - setIsSaving(true); - const success = await updateCell(rowId, columnId, editValue); - - if (success) { - onSave?.(row, column, editValue); - setIsEditing(false); - } else { - setIsSaving(false); - } - }, [editValue, value, updateCell, rowId, columnId, userId, onSave, row, column, unlockCell]); - - const handleKeyDown = useCallback((e) => { - if (e.key === 'Enter') { - handleSave(); - } else if (e.key === 'Escape') { - setIsEditing(false); - setEditValue(value); - unlockCell(rowId, columnId, userId); - } - }, [handleSave, value, rowId, columnId, userId, unlockCell]); - - // Отмена блокировки при размонтировании, если ячейка редактировалась - useEffect(() => { - return () => { - if (isEditing) { - unlockCell(rowId, columnId, userId); - } - }; - }, [isEditing, rowId, columnId, userId, unlockCell]); - - const getLockTooltip = () => { - if (!isEditable) return 'This cell is not editable'; - if (isLocked && !isLockedByMe) { - return `Editing by user ${lockInfo?.userId}`; - } - if (isLockedByMe && isEditing) { - return 'You are editing this cell'; - } - return 'Double-click to edit'; - }; - - const getCellStyle = () => { - const baseStyle = { - padding: '8px', - minHeight: '40px', - position: 'relative', - }; - - if (!isEditable) { - return { - ...baseStyle, - backgroundColor: '#fafafa', - color: '#999', - cursor: 'not-allowed', - }; - } - - if (isLocked && !isLockedByMe) { - return { - ...baseStyle, - backgroundColor: '#f5f5f5', - opacity: 0.7, - cursor: 'not-allowed', - }; - } - - return { - ...baseStyle, - cursor: 'text', - '&:hover': { - backgroundColor: '#f0f0f0', - }, - }; - }; - - if (isEditing) { - return ( - - setEditValue(e.target.value)} - onBlur={handleSave} - onKeyDown={handleKeyDown} - disabled={isSaving} - style={{ - width: '100%', - padding: '8px', - border: '2px solid #1976d2', - borderRadius: '4px', - fontSize: 'inherit', - outline: 'none', - }} - /> - {isSaving && ( - - )} - - ); - } - - return ( - - + regex.test(part) ? ( + - {value || ''} - {isLockedByMe && !isEditing && ( - - )} - - + {part} + + ) : ( + part + ), ); }; -export default Cell; \ No newline at end of file +const Cell = React.memo(({ cell, row, column, onClick, globalFilter, backgroundColor, color }) => { + const cellValue = cell.getValue(); + const textValue = String(cellValue || ''); + + // Подсвечиваем текст с учетом поискового запроса + const highlightedContent = useMemo(() => { + return highlightText(textValue, globalFilter); + }, [textValue, globalFilter]); + + const { + isCellLocked, + getCellLockInfo, + updateCell, + subscribeToErrors, + tryLockCell, + unlockCell + } = useRealtime(); + + + return ( +
+ {highlightedContent} +
+ ); +}); + +export default Cell; diff --git a/web/src/components/RealtimeTable/RealtimeTable.jsx b/web/src/components/RealtimeTable/RealtimeTable.jsx index 6287157..d322b4b 100644 --- a/web/src/components/RealtimeTable/RealtimeTable.jsx +++ b/web/src/components/RealtimeTable/RealtimeTable.jsx @@ -1,10 +1,10 @@ -import React, { useState, useMemo, useRef, useEffect } from 'react'; +// components/RealtimeTable/index.js +import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { useMaterialReactTable, MaterialReactTable, } from 'material-react-table'; -import { CircularProgress, Typography, Box } from '@mui/material'; import useRealtimeData from './hooks/useRealtimeData'; import { getTableColumns } from './tableColumns'; @@ -20,33 +20,28 @@ import { getTableHeadCellStyles, getTablePaperStyles, } from './constants/tableConfig'; +import { useRealtime } from './contexts/RealtimeContext'; -// Импортируем WebSocket контекст -import { RealtimeProvider, useRealtime } from './contexts/RealtimeContext'; - -// Внутренний компонент, который будет использовать WebSocket -const RealtimeTableContent = ({ formType, formId, sheetName }) => { - // Получаем данные и состояние из хука - const { - data, - setData, - isConnected, - isLoading: isDataLoading, - error: wsError - } = useRealtimeData(formId, sheetName); - // isConnected = true - - // Получаем функции из WebSocket контекста - const { login, releaseAllLocks } = useRealtime(); - +const RealtimeTable = ({ formType, formId, sheetName }) => { + const { data, setData } = useRealtimeData(formId, sheetName); + const [isLoading, setIsLoading] = useState(true); const [globalFilter, setGlobalFilter] = useState(''); const [showColumnFilters, setShowColumnFilters] = useState(false); const [selectedColumn, setSelectedColumn] = useState(); - const [columnsConfig, setColumnsConfig] = useState(null); - const [userId, setUserId] = useState(null); + const [updatingCells, setUpdatingCells] = useState({}); const columnVirtualizerInstanceRef = useRef(null); + const { + isConnected, + isAuthenticated, + subscribeToCellUpdates, + subscribeToErrors, + subscribeToAuthSuccess, + error: wsError, + updateCell: contextUpdateCell + } = useRealtime(); + const { columnSizing, columnPinning, @@ -60,38 +55,19 @@ const RealtimeTableContent = ({ formType, formId, sheetName }) => { } = useColumnSettings(); const { headerPortalRef, containerRef } = useHeaderPortal(); - const { sizeMult, setSizeMult, tableScaleStyle, tableWrapperStyle } = useTableScale(containerRef); - // Получаем ID пользователя - useEffect(() => { - const getUserId = async () => { - try { - const token = localStorage.getItem('access_token'); - if (token) { - // Декодируем JWT токен - const payload = JSON.parse(atob(token.split('.')[1])); - setUserId(payload.user_id || payload.sub || payload.id); - } else { - // Для разработки - setUserId('anonymous_' + Date.now()); - } - } catch (error) { - console.error('Failed to get user ID:', error); - setUserId('anonymous_' + Date.now()); - } - }; + const { sizeMult, setSizeMult, tableScaleStyle, tableWrapperStyle } = + useTableScale(containerRef); - getUserId(); - }, []); + const [columnsConfig, setColumnsConfig] = useState(null); - // Загрузка конфигурации колонок useEffect(() => { const loadConfig = async () => { try { const { config } = await import(`./constants/${formType}/${sheetName}.js`); setColumnsConfig(config.config); } catch (error) { - console.error(`Failed to load data for ${formType}/${sheetName}:`, error); + console.error(`Failed to load config for ${formType}/${sheetName}:`, error); setColumnsConfig({ columns: [], colors: {} }); } }; @@ -99,66 +75,108 @@ const RealtimeTableContent = ({ formType, formId, sheetName }) => { loadConfig(); }, [formType, sheetName]); - // Логинимся в WebSocket + // Подписка на обновления от других пользователей useEffect(() => { - if (isConnected && userId) { - const token = localStorage.getItem('access_token'); - if (token) { - login(token); + const unsubscribe = subscribeToCellUpdates((updatedCells) => { + console.log('Received updates from other users:', updatedCells); + + setData(prevData => { + if (!prevData) return prevData; + const newData = [...prevData]; + updatedCells.forEach(update => { + const rowIndex = newData.findIndex(row => row.id === update.line_id); + if (rowIndex !== -1) { + newData[rowIndex] = { + ...newData[rowIndex], + [update.column]: update.value + }; + } + }); + return newData; + }); + }); + + return unsubscribe; + }, [subscribeToCellUpdates, setData]); + + // Подписка на ошибки + useEffect(() => { + const unsubscribe = subscribeToErrors((errorMsg) => { + console.error('WebSocket error:', errorMsg); + // Можно добавить Toast уведомление + }); + + return unsubscribe; + }, [subscribeToErrors]); + + // Подписка на успешную авторизацию + useEffect(() => { + const unsubscribe = subscribeToAuthSuccess(() => { + console.log('Successfully authenticated with WebSocket'); + setIsLoading(false); + }); + + return unsubscribe; + }, [subscribeToAuthSuccess]); + + // Функция для установки состояния обновления ячейки + const setUpdatingCell = useCallback((rowId, columnId, isUpdating) => { + setUpdatingCells(prev => ({ + ...prev, + [`${rowId}_${columnId}`]: isUpdating + })); + }, []); + + const handleUpdateCell = useCallback(async (rowId, columnId, value) => { + setData(prevData => { + const newData = [...prevData]; + const rowIndex = newData.findIndex(row => row.id === rowId); + if (rowIndex !== -1) { + newData[rowIndex] = { + ...newData[rowIndex], + [columnId]: value + }; } - } - }, [isConnected, userId, login]); + return newData; + }); - // Освобождаем блокировки при размонтировании - useEffect(() => { - return () => { - releaseAllLocks(); - }; - }, [releaseAllLocks]); + // Отправляем через WebSocket + return await contextUpdateCell(rowId, columnId, value); + }, [contextUpdateCell, setData]); - // Обработчик сохранения ячейки (теперь только для оптимистичного обновления UI) - const handleSaveCell = async (row, column, value) => { - // Обновление данных происходит через WebSocket в Cell компоненте - // Здесь просто обновляем UI для оптимистичного отображения - const updatedData = [...data]; - const rowIndex = data.findIndex(r => (r.id || r.line_id || r.index) === row.id); - if (rowIndex !== -1) { - updatedData[rowIndex][column.id] = value; - setData(updatedData); - } - }; - - // Создаем колонки с передачей userId в Cell + // Создание колонок с интеграцией WebSocket const columns = useMemo(() => { if (!columnsConfig || !columnsConfig.columns) return []; - try { - // Оборачиваем Cell компонент для передачи userId - const CellWithProps = (props) => ( - - ); - return getTableColumns({ - Cell: CellWithProps, + Cell, EditCell, - columnsConfig + columnsConfig, + onCellUpdate: handleUpdateCell, // Передаем функцию обновления + onCellUpdateError: (rowId, columnId, error) => { + console.error(`Error updating cell ${rowId}_${columnId}:`, error); + // Можно добавить Toast уведомление + } }); } catch (error) { console.error('Error creating columns:', error); return []; } - }, [columnsConfig, userId, data]); + }, [columnsConfig, handleUpdateCell]); - // Конфигурация таблицы (без изменений) + // Метаданные для таблицы + const tableMeta = useMemo(() => ({ + updatingCells, + setUpdatingCell, + updateCell: handleUpdateCell, + }), [updatingCells, setUpdatingCell]); + + // Конфигурация таблицы const tableConfig = useMemo( () => ({ ...BASE_TABLE_CONFIG, columns, - data, + data: data || [], columnVirtualizerInstanceRef, enableRowVirtualization: false, onColumnSizingChange: setColumnSizing, @@ -171,6 +189,7 @@ const RealtimeTableContent = ({ formType, formId, sheetName }) => { globalFilter, showColumnFilters, }, + meta: tableMeta, muiTableHeadCellProps: ({ column }) => ({ sx: { width: column.getSize(), @@ -224,41 +243,48 @@ const RealtimeTableContent = ({ formType, formId, sheetName }) => { globalFilter, showColumnFilters, containerRef, + tableMeta, ], ); const table = useMaterialReactTable(tableConfig); - // Показываем индикатор загрузки - if (isDataLoading) { - return ( - - - Загрузка данных... - - ); - } + // Индикатор подключения WebSocket + const ConnectionStatus = () => ( +
+ {console.log(isConnected ,isAuthenticated )} + + {isConnected && isAuthenticated ? '● Connected' : '○ Disconnected'} + {wsError &&
{wsError}
} +
+ ); - // Показываем ошибку подключения - if (!isConnected) { - return ( - - - Подключение к серверу... - - ); - } - - if (wsError) { - return ( - - Ошибка подключения: {wsError} - - ); + if (!data) { + return
Loading...
; } return ( <> + { ); }; -// Основной компонент с провайдером WebSocket -const RealtimeTable = ({ formType, formId, sheetName }) => { - const [direction, setDirection] = useState(null); - - // Получаем direction из URL или пропсов если нужно - useEffect(() => { - // Пример получения direction из URL параметров - const urlParams = new URLSearchParams(window.location.search); - const dir = urlParams.get('direction'); - if (dir) { - setDirection(dir); - } - }, []); - - return ( - - - - ); -}; - export default React.memo(RealtimeTable); \ No newline at end of file diff --git a/web/src/components/RealtimeTable/contexts/RealtimeContext.js b/web/src/components/RealtimeTable/contexts/RealtimeContext.js index 800eea8..c36ce46 100644 --- a/web/src/components/RealtimeTable/contexts/RealtimeContext.js +++ b/web/src/components/RealtimeTable/contexts/RealtimeContext.js @@ -1,176 +1,296 @@ -import React, { createContext, useContext, useCallback, useRef, useState } from 'react'; -import { useWebSocket } from '../hooks/useWebSocket'; -import { useCellLocks } from '../hooks/useCellLocks'; -import { Config } from '../../../conf/config'; +import React, { + createContext, + useContext, + useCallback, + useRef, + useState, + useEffect, +} from "react"; +import { useWebSocket } from "../hooks/useWebSocket"; +import { useCellLocks } from "../hooks/useCellLocks"; const RealtimeContext = createContext(null); export const useRealtime = () => { const context = useContext(RealtimeContext); if (!context) { - throw new Error('useRealtime must be used within RealtimeProvider'); + throw new Error("useRealtime must be used within RealtimeProvider"); } return context; }; -export const RealtimeProvider = ({ children, formId, sheetName, direction, userId }) => { +export const RealtimeProvider = ({ + children, + formId, + sheetName, + direction, + userId, +}) => { + console.log(children, formId, sheetName, direction, userId); const [pendingUpdates, setPendingUpdates] = useState(new Map()); - // Формируем URL в соответствии с вашим бэкендом - const wsUrl = `${process.env.REACT_APP_WS_URL || 'ws://localhost:8000'}/ws/form/${formId}/sheet/${sheetName}${direction ? `?direction=${direction}` : ''}`; - - const { isConnected, sendMessage, login, error } = useWebSocket(wsUrl, handleMessage); + + // Формируем URL + const wsUrl = `${process.env.REACT_APP_WS_URL || "ws://localhost:8000"}/api/v1/ws/form/${formId}/sheet/${sheetName}${direction ? `?direction=${direction}` : ""}`; + + const handleMessage = useCallback((data) => { + console.log("Received message:", data); + + // Обработка ошибок + if (data.error) { + console.error("Server error:", data.error); + onErrorRef.current?.(data.error); + return; + } + + // Обработка событий + switch (data.event) { + case "cell_updated": + if (data.result && onCellUpdateRef.current) { + const updatedCells = Array.isArray(data.result) + ? data.result + : [data.result]; + onCellUpdateRef.current(updatedCells); + } + break; + + case "row_added": + if (data.result && onRowAddRef.current) { + onRowAddRef.current(data.result); + } + break; + + case "row_deleted": + if (data.result && onRowDeleteRef.current) { + onRowDeleteRef.current(data.result); + } + break; + + default: + console.log("Unknown event:", data.event); + } + }, []); + + const { isConnected, sendMessage, disconnect, lastError } = useWebSocket( + wsUrl, + handleMessage, + ); const { isCellLocked, tryLockCell, unlockCell, getCellLockInfo, - releaseAllLocks - } = useCellLocks(userId); + releaseAllLocks, + } = useCellLocks(null); const onCellUpdateRef = useRef(null); const onRowAddRef = useRef(null); const onRowDeleteRef = useRef(null); const onErrorRef = useRef(null); + const onAuthSuccessRef = useRef(null); + const authAttemptedRef = useRef(false); + const reconnectTimerRef = useRef(null); - function handleMessage(data) { - // Обработка ошибок от бэкенда - if (data.error) { - onErrorRef.current?.(data.error); - return; - } + // Функция для получения токена из localStorage + const getAccessToken = useCallback(() => { + try { + const token = localStorage.getItem("access_token"); - // Ваш бэкенд возвращает result после успешной операции - switch (data.event) { - case 'cell_updated': - if (data.result && onCellUpdateRef.current) { - // Ваш бэкенд возвращает результат в виде списка обновленных ячеек - // Формат: [{ line_id, column, value }] - const updatedCells = Array.isArray(data.result) ? data.result : [data.result]; - onCellUpdateRef.current(updatedCells); - } - break; - - case 'row_added': - if (data.result && onRowAddRef.current) { - // Ваш бэкенд возвращает добавленную строку - onRowAddRef.current(data.result); - } - break; - - case 'row_deleted': - if (data.result && onRowDeleteRef.current) { - // Ваш бэкенд возвращает ID удаленной строки - onRowDeleteRef.current(data.result); - } - break; - - case 'user_login': - console.log('User logged in:', data.user_id); - break; - - default: - console.log('Unknown event:', data.event); - } - } - - const updateCell = useCallback(async (rowId, columnId, value) => { - // Пытаемся заблокировать ячейку - if (!tryLockCell(rowId, columnId, userId)) { - const lockInfo = getCellLockInfo(rowId, columnId); - onErrorRef.current?.(`Ячейка редактируется пользователем ${lockInfo?.userId}`); - return false; - } - - // Формируем сообщение в формате вашего бэкенда - const message = { - event: 'cell_updated', - data: { - line_id: rowId, - column: columnId, - value: value + if (!token) { + console.warn("No access token found in localStorage"); + return null; } - }; - // Сохраняем pending update для оптимистичного UI - const updateKey = `${rowId}_${columnId}`; - setPendingUpdates(prev => new Map(prev).set(updateKey, { value, timestamp: Date.now() })); + return token; + } catch (error) { + console.error("Error reading token from localStorage:", error); + return null; + } + }, []); - const sent = sendMessage(message); - - if (!sent) { - // Откатываем блокировку если сообщение не отправлено - unlockCell(rowId, columnId, userId); - setPendingUpdates(prev => { - const newMap = new Map(prev); - newMap.delete(updateKey); - return newMap; - }); + const sendLogin = useCallback(() => { + const token = getAccessToken(); + if (!token) { + console.error("Cannot login: no token available"); + onErrorRef.current?.("Токен авторизации не найден"); return false; } - - return true; - }, [sendMessage, tryLockCell, unlockCell, getCellLockInfo, userId]); - const addRow = useCallback(async (rowData) => { - const message = { - event: 'row_added', - data: rowData + console.log("Sending login message with token"); + const loginMessage = { + event: "user_login", + data: { token }, }; - - return sendMessage(message); - }, [sendMessage]); - const deleteRow = useCallback(async (rowId) => { - const message = { - event: 'row_deleted', - data: { row_id: rowId } - }; - - return sendMessage(message); - }, [sendMessage]); + return sendMessage(loginMessage); + }, [getAccessToken, sendMessage]); + + useEffect(() => { + if (isConnected && !authAttemptedRef.current) { + authAttemptedRef.current = true; + console.log("WebSocket connected, sending login..."); + setTimeout(() => { + sendLogin(); + }, 100); + } + }, [isConnected, sendLogin]); + + useEffect(() => { + if (!isConnected) { + authAttemptedRef.current = false; + } + }, [isConnected]); + + const updateCell = useCallback( + async (rowId, columnId, value) => { + if (!isConnected) { + onErrorRef.current?.("WebSocket не подключен"); + return false; + } + + if (!tryLockCell(rowId, columnId, userId)) { + const lockInfo = getCellLockInfo(rowId, columnId); + onErrorRef.current?.( + `Ячейка редактируется пользователем ${lockInfo?.userId}`, + ); + return false; + } + + const message = { + event: "cell_updated", + data: { + line_id: rowId, + column: columnId, + value: value, + }, + }; + + const updateKey = `${rowId}_${columnId}`; + setPendingUpdates((prev) => + new Map(prev).set(updateKey, { value, timestamp: Date.now() }), + ); + + const sent = sendMessage(message); + + if (!sent) { + unlockCell(rowId, columnId, userId); + setPendingUpdates((prev) => { + const newMap = new Map(prev); + newMap.delete(updateKey); + return newMap; + }); + return false; + } + + return true; + }, + [ + isConnected, + sendMessage, + tryLockCell, + unlockCell, + getCellLockInfo, + userId, + ], + ); + + const addRow = useCallback( + async (rowData) => { + if (!isConnected) { + onErrorRef.current?.("Нет подключения или авторизации"); + return false; + } + + const message = { + event: "row_added", + data: rowData, + }; + + return sendMessage(message); + }, + [isConnected, sendMessage], + ); + + const deleteRow = useCallback( + async (rowId) => { + if (!isConnected ) { + onErrorRef.current?.("Нет подключения или авторизации"); + return false; + } + + const message = { + event: "row_deleted", + data: { row_id: rowId }, + }; + + return sendMessage(message); + }, + [isConnected, sendMessage], + ); const subscribeToCellUpdates = useCallback((callback) => { onCellUpdateRef.current = callback; - return () => { onCellUpdateRef.current = null; }; + return () => { + onCellUpdateRef.current = null; + }; }, []); const subscribeToRowAdds = useCallback((callback) => { onRowAddRef.current = callback; - return () => { onRowAddRef.current = null; }; + return () => { + onRowAddRef.current = null; + }; }, []); const subscribeToRowDeletes = useCallback((callback) => { onRowDeleteRef.current = callback; - return () => { onRowDeleteRef.current = null; }; + return () => { + onRowDeleteRef.current = null; + }; }, []); const subscribeToErrors = useCallback((callback) => { onErrorRef.current = callback; - return () => { onErrorRef.current = null; }; + return () => { + onErrorRef.current = null; + }; }, []); - const loginUser = useCallback((token) => { - login(token); - }, [login]); + const subscribeToAuthSuccess = useCallback((callback) => { + onAuthSuccessRef.current = callback; + return () => { + onAuthSuccessRef.current = null; + }; + }, []); + + const retryLogin = useCallback(() => { + if (isConnected) { + authAttemptedRef.current = false; + sendLogin(); + } + }, [isConnected, sendLogin]); return ( - isCellLocked(rowId, columnId, userId), - getCellLockInfo: (rowId, columnId) => getCellLockInfo(rowId, columnId), - tryLockCell: (rowId, columnId) => tryLockCell(rowId, columnId, userId), - subscribeToCellUpdates, - subscribeToRowAdds, - subscribeToRowDeletes, - subscribeToErrors, - login: loginUser, - releaseAllLocks: () => releaseAllLocks(userId) - }}> + + isCellLocked(rowId, columnId, userId), + getCellLockInfo: (rowId, columnId) => getCellLockInfo(rowId, columnId), + tryLockCell: (rowId, columnId) => tryLockCell(rowId, columnId, userId), + subscribeToCellUpdates, + subscribeToRowAdds, + subscribeToRowDeletes, + subscribeToErrors, + subscribeToAuthSuccess, + retryLogin, + releaseAllLocks: () => releaseAllLocks(userId), + }} + > {children} ); -}; \ No newline at end of file +}; diff --git a/web/src/components/RealtimeTable/hooks/useTableWebSocket.js b/web/src/components/RealtimeTable/hooks/useTableWebSocket.js new file mode 100644 index 0000000..8e28018 --- /dev/null +++ b/web/src/components/RealtimeTable/hooks/useTableWebSocket.js @@ -0,0 +1,165 @@ +// hooks/useTableWebSocket.js +import { useWebSocket } from './useWebSocket'; +import { useCallback, useEffect, useRef } from 'react'; + +export const useTableWebSocket = (formId, sheet, direction = null) => { + // Формируем URL WebSocket + const wsUrl = `${process.env.REACT_APP_WS_URL || 'ws://localhost:8000'}/ws/form/${formId}/sheet/${sheet}${direction ? `?direction=${direction}` : ''}`; + + const pendingChanges = useRef(new Map()); // Храним ожидающие подтверждения изменения + const callbacks = useRef(new Map()); // Храним колбэки для каждого изменения + + const handleWebSocketMessage = useCallback((data) => { + console.log('Received WebSocket message:', data); + + // Обработка подтверждения изменения + if (data.event === 'cell_updated' && data.result) { + const changeKey = `${data.data.line_id}_${data.data.column}`; + const callback = callbacks.current.get(changeKey); + + if (callback) { + // Успешное обновление + callback.success?.(data.result); + callbacks.current.delete(changeKey); + pendingChanges.current.delete(changeKey); + } + } + + // Обработка ошибок + if (data.error) { + const changeKey = `${data.data?.line_id}_${data.data?.column}`; + const callback = callbacks.current.get(changeKey); + + if (callback) { + callback.error?.(data.error); + callbacks.current.delete(changeKey); + pendingChanges.current.delete(changeKey); + } + } + + // Обработка обновлений от других пользователей + if (data.event === 'cell_updated' && data.result) { + // Обновляем таблицу данными от других пользователей + if (window.dispatchEvent) { + window.dispatchEvent(new CustomEvent('table-data-update', { + detail: { + lineId: data.data.line_id, + column: data.data.column, + value: data.data.value, + sourceUserId: data.user_id + } + })); + } + } + }, []); + + const { isConnected, error, sendMessage, login, disconnect } = useWebSocket( + wsUrl, + handleWebSocketMessage + ); + + // Отправка обновления ячейки + const updateCell = useCallback((lineId, column, value, onSuccess, onError) => { + if (!isConnected) { + onError?.('WebSocket not connected'); + return false; + } + + const message = { + event: 'cell_updated', + data: { + line_id: lineId, + column: column, + value: value + } + }; + + const changeKey = `${lineId}_${column}`; + + // Сохраняем колбэки + callbacks.current.set(changeKey, { success: onSuccess, error: onError }); + pendingChanges.current.set(changeKey, message); + + // Отправляем сообщение + const sent = sendMessage(message); + + if (!sent) { + callbacks.current.delete(changeKey); + pendingChanges.current.delete(changeKey); + onError?.('Failed to send message'); + } + + return sent; + }, [isConnected, sendMessage]); + + // Добавление строки + const addRow = useCallback((rowData, onSuccess, onError) => { + if (!isConnected) { + onError?.('WebSocket not connected'); + return false; + } + + const message = { + event: 'row_added', + data: rowData + }; + + const sent = sendMessage(message); + + if (!sent) { + onError?.('Failed to send message'); + } + + return sent; + }, [isConnected, sendMessage]); + + // Удаление строки + const deleteRow = useCallback((rowId, onSuccess, onError) => { + if (!isConnected) { + onError?.('WebSocket not connected'); + return false; + } + + const message = { + event: 'row_deleted', + data: { + row_id: rowId + } + }; + + const sent = sendMessage(message); + + if (!sent) { + onError?.('Failed to send message'); + } + + return sent; + }, [isConnected, sendMessage]); + + // Авторизация при подключении + useEffect(() => { + if (isConnected) { + const token = localStorage.getItem('token'); // или откуда вы получаете токен + if (token) { + login(token); + } + } + }, [isConnected, login]); + + // Очистка при размонтировании + useEffect(() => { + return () => { + callbacks.current.clear(); + pendingChanges.current.clear(); + }; + }, []); + + return { + isConnected, + error, + updateCell, + addRow, + deleteRow, + disconnect + }; +}; \ No newline at end of file diff --git a/web/src/components/RealtimeTable/hooks/useWebSocket.js b/web/src/components/RealtimeTable/hooks/useWebSocket.js index bc82ee5..a395e53 100644 --- a/web/src/components/RealtimeTable/hooks/useWebSocket.js +++ b/web/src/components/RealtimeTable/hooks/useWebSocket.js @@ -34,12 +34,12 @@ export const useWebSocket = (url, onMessage) => { ws.onerror = (event) => { console.error('WebSocket error:', event); - // Не закрываем соединение при ошибке, просто логируем + console.error('WebSocket readyState:', ws.readyState); setError('Connection error occurred'); }; ws.onclose = (event) => { - console.log('WebSocket closed:', event.code, event.reason); + console.log('WebSocket closed:', event.code, event.reason, 'Clean:', event.wasClean); setIsConnected(false); // Не переподключаемся если закрыто intentionally или с ошибкой авторизации diff --git a/web/src/components/RealtimeTable/tableColumns.jsx b/web/src/components/RealtimeTable/tableColumns.jsx index 1d3a5e5..7afd9c4 100644 --- a/web/src/components/RealtimeTable/tableColumns.jsx +++ b/web/src/components/RealtimeTable/tableColumns.jsx @@ -3,9 +3,18 @@ import { createPortal } from 'react-dom'; import { columns as mockColumns } from '../../mocks/mockTable'; import { config } from './constants/FORM_2/AHR'; -function EditCellPortal({ cell, table, EditCell, onChange }) { +function EditCellPortal({ + cell, + table, + EditCell, + onChange, + onSaveStart, + onSaveEnd, + onError +}) { const ref = useRef(null); const [refTbody, setRefTbody] = useState(false); + const [isSaving, setIsSaving] = useState(false); const isDisabled = false; useEffect(() => { @@ -15,6 +24,60 @@ function EditCellPortal({ cell, table, EditCell, onChange }) { } }, []); + const handleChange = async (val) => { + if (isSaving) return; + + setIsSaving(true); + onSaveStart?.(); + + // Сохраняем старое значение на случай ошибки + const oldValue = cell.getValue(); + console.log(table.options.meta) + + try { + // Сначала обновляем локальное состояние (оптимистичное обновление) + onChange(val); + + // Используем updateCell из контекста (передается через meta) + if (table.options.meta?.updateCell) { + const success = await table.options.meta.updateCell( + cell.row.id, + cell.column.id, + val + ); + + if (success) { + console.log('Cell updated successfully via WebSocket'); + onSaveEnd?.(true); + table.setEditingCell(null); + } else { + console.error('Failed to update cell via WebSocket'); + const errorMsg = 'Не удалось сохранить изменение'; + onError?.(errorMsg); + onSaveEnd?.(false); + // Возвращаем предыдущее значение + onChange(oldValue); + } + } else { + // Если updateCell нет в meta, используем onCellUpdate из пропсов (fallback) + console.warn('WebSocket updateCell not configured in meta'); + if (table.options.meta?.onCellUpdate) { + table.options.meta.onCellUpdate(cell.row.id, cell.column.id, val); + } + onSaveEnd?.(true); + table.setEditingCell(null); + } + } catch (error) { + console.error('Error updating cell:', error); + onError?.(error.message); + onSaveEnd?.(false); + // Возвращаем предыдущее значение + onChange(oldValue); + } finally { + setIsSaving(false); + } + }; + return ( <>
- {refTbody && + {refTbody && !isSaving && createPortal( { - onChange(val); - table.setEditingCell(null); // Закрываем после сохранения - }} + disabled={isDisabled || isSaving} + onChange={handleChange} />, refTbody, )} + {isSaving && ( + createPortal( +
+ Saving... +
, + refTbody + ) + )} ); } -export const getTableColumns = ({ Cell, EditCell, columnsConfig }) => { - +export const getTableColumns = ({ + Cell, + EditCell, + columnsConfig, + onCellUpdateError +}) => { const columnColors = { ...columnsConfig.colors }; const columns = [...columnsConfig.columns]; const processColumns = (columns, EditCell, Cell) => { columns.forEach((col) => { col.Cell = ({ cell, table, column, row }) => ( - <> - - - - + ); + col.Edit = ({ cell, table, row, column }) => ( { - row._valuesCache[column.id] = value; + // Локальное обновление кэша + if (row._valuesCache) { + row._valuesCache[column.id] = value; + } }} + onSaveStart={() => { + if (table.options.meta?.setUpdatingCell) { + table.options.meta.setUpdatingCell(row.id, column.id, true); + } + }} + onSaveEnd={(success) => { + if (table.options.meta?.setUpdatingCell) { + table.options.meta.setUpdatingCell(row.id, column.id, false); + } + if (!success && onCellUpdateError) { + onCellUpdateError(row.id, column.id, 'Failed to save'); + } + }} + onError={onCellUpdateError} /> ); @@ -96,4 +192,4 @@ export const getTableColumns = ({ Cell, EditCell, columnsConfig }) => { }; return [rowNumber, ...columns]; -}; +}; \ No newline at end of file diff --git a/web/src/pages/NewFormTablePage.jsx b/web/src/pages/NewFormTablePage.jsx index 16f5025..b6bd54d 100644 --- a/web/src/pages/NewFormTablePage.jsx +++ b/web/src/pages/NewFormTablePage.jsx @@ -5,6 +5,8 @@ import RealtimeTable from '../components/RealtimeTable'; import { useParams } from 'react-router-dom'; import { useEffect } from 'react'; import { } from '../components/RealtimeTable/' +import { RealtimeProvider } from '../components/RealtimeTable/contexts/RealtimeContext'; +import { useAuth } from '../app/context/AuthProvider'; const TableInfo = () => { const portalContent = ( @@ -23,10 +25,21 @@ const TableInfo = () => { export default function NewTablePage() { const { formId, formType, sheetName } = useParams() + const { user } = useAuth(); return ( <> - + {user && ( + + + + + )} + ); } diff --git a/web/src/pages/NewTablePage.jsx b/web/src/pages/NewTablePage.jsx index a2e760e..6e91c92 100644 --- a/web/src/pages/NewTablePage.jsx +++ b/web/src/pages/NewTablePage.jsx @@ -2,6 +2,7 @@ import { createPortal } from 'react-dom'; import { TaskInfoContainer } from '../components/common/SwitchFormTask/SwitchFormTask.style'; import RealtimeTable from '../components/RealtimeTable'; +import { RealtimeProvider } from '../components/RealtimeTable/contexts/RealtimeContext'; const TableInfo = () => { const portalContent = ( @@ -21,7 +22,9 @@ const TableInfo = () => { export default function NewTablePage() { return ( <> - + + ); }