ws - в процессе

This commit is contained in:
PotapovaA 2026-05-27 16:21:04 +03:00
parent 8b16901f1b
commit 079e719f71
8 changed files with 765 additions and 491 deletions

View File

@ -1,12 +1,55 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React from 'react';
import { Box, Tooltip, CircularProgress } from '@mui/material'; import { useMemo } from 'react';
import styled from '@emotion/styled';
import { useRealtime } from '../../contexts/RealtimeContext'; import { useRealtime } from '../../contexts/RealtimeContext';
const Cell = ({ value, row, column, onSave, userId, isEditable = true }) => {
const [isEditing, setIsEditing] = useState(false); const ContainerForText = styled.span`
const [editValue, setEditValue] = useState(value); display: -webkit-box;
const [isSaving, setIsSaving] = useState(false); -webkit-box-orient: vertical;
const [lockInfo, setLockInfo] = useState(null); -webkit-line-clamp: 3;
overflow: hidden;
white-space: pre-wrap;
`;
// Функция для подсветки текста
const highlightText = (text, searchQuery) => {
if (!searchQuery || !text) return text;
// Экранируем спецсимволы в поисковом запросе
const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedQuery})`, 'gi');
const parts = text.split(regex);
return parts.map((part, index) =>
regex.test(part) ? (
<mark
key={index}
style={{
backgroundColor: '#ffeb3b',
color: '#000',
fontWeight: 'bold',
padding: '0 2px',
borderRadius: '2px',
}}
>
{part}
</mark>
) : (
part
),
);
};
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 { const {
isCellLocked, isCellLocked,
@ -17,194 +60,32 @@ const Cell = ({ value, row, column, onSave, userId, isEditable = true }) => {
unlockCell unlockCell
} = useRealtime(); } = 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]);
// Подписка на ошибки
useEffect(() => {
const unsubscribe = subscribeToErrors((error) => {
console.error('Cell update error:', error);
setIsSaving(false);
setIsEditing(false);
});
return unsubscribe;
}, [subscribeToErrors]);
const isLocked = isCellLocked(rowId, columnId);
const isLockedByMe = lockInfo?.userId === userId;
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 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 (
<Box sx={{ position: 'relative', width: '100%' }}>
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => 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 && (
<CircularProgress
size={20}
sx={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
}}
/>
)}
</Box>
);
}
return ( return (
<Tooltip title={getLockTooltip()} placement="top" arrow> <div
<Box className="cell"
onDoubleClick={handleDoubleClick} style={{
sx={getCellStyle()} width: '100%',
> height: '100%',
{value || ''} display: 'flex',
{isLockedByMe && !isEditing && ( alignItems: 'center',
<Box justifyContent: 'center',
sx={{ boxSizing: 'border-box',
position: 'absolute', position: 'absolute',
top: 2, top: 0,
right: 2, left: 0,
width: 8, right: 0,
height: 8, bottom: 0,
borderRadius: '50%', border: '2px solid transparent',
backgroundColor: '#ff9800', padding: '8px',
}} backgroundColor: backgroundColor,
/> color: color,
)} }}
</Box> onClick={onClick}
</Tooltip> >
<ContainerForText>{highlightedContent}</ContainerForText>
</div>
); );
}; });
export default Cell; export default Cell;

View File

@ -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 { createPortal } from 'react-dom';
import { import {
useMaterialReactTable, useMaterialReactTable,
MaterialReactTable, MaterialReactTable,
} from 'material-react-table'; } from 'material-react-table';
import { CircularProgress, Typography, Box } from '@mui/material';
import useRealtimeData from './hooks/useRealtimeData'; import useRealtimeData from './hooks/useRealtimeData';
import { getTableColumns } from './tableColumns'; import { getTableColumns } from './tableColumns';
@ -20,33 +20,28 @@ import {
getTableHeadCellStyles, getTableHeadCellStyles,
getTablePaperStyles, getTablePaperStyles,
} from './constants/tableConfig'; } from './constants/tableConfig';
import { useRealtime } from './contexts/RealtimeContext';
// Импортируем WebSocket контекст const RealtimeTable = ({ formType, formId, sheetName }) => {
import { RealtimeProvider, useRealtime } from './contexts/RealtimeContext'; const { data, setData } = useRealtimeData(formId, sheetName);
const [isLoading, setIsLoading] = useState(true);
// Внутренний компонент, который будет использовать 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 [globalFilter, setGlobalFilter] = useState(''); const [globalFilter, setGlobalFilter] = useState('');
const [showColumnFilters, setShowColumnFilters] = useState(false); const [showColumnFilters, setShowColumnFilters] = useState(false);
const [selectedColumn, setSelectedColumn] = useState(); const [selectedColumn, setSelectedColumn] = useState();
const [columnsConfig, setColumnsConfig] = useState(null); const [updatingCells, setUpdatingCells] = useState({});
const [userId, setUserId] = useState(null);
const columnVirtualizerInstanceRef = useRef(null); const columnVirtualizerInstanceRef = useRef(null);
const {
isConnected,
isAuthenticated,
subscribeToCellUpdates,
subscribeToErrors,
subscribeToAuthSuccess,
error: wsError,
updateCell: contextUpdateCell
} = useRealtime();
const { const {
columnSizing, columnSizing,
columnPinning, columnPinning,
@ -60,38 +55,19 @@ const RealtimeTableContent = ({ formType, formId, sheetName }) => {
} = useColumnSettings(); } = useColumnSettings();
const { headerPortalRef, containerRef } = useHeaderPortal(); const { headerPortalRef, containerRef } = useHeaderPortal();
const { sizeMult, setSizeMult, tableScaleStyle, tableWrapperStyle } = useTableScale(containerRef);
// Получаем ID пользователя const { sizeMult, setSizeMult, tableScaleStyle, tableWrapperStyle } =
useEffect(() => { useTableScale(containerRef);
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());
}
};
getUserId(); const [columnsConfig, setColumnsConfig] = useState(null);
}, []);
// Загрузка конфигурации колонок
useEffect(() => { useEffect(() => {
const loadConfig = async () => { const loadConfig = async () => {
try { try {
const { config } = await import(`./constants/${formType}/${sheetName}.js`); const { config } = await import(`./constants/${formType}/${sheetName}.js`);
setColumnsConfig(config.config); setColumnsConfig(config.config);
} catch (error) { } 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: {} }); setColumnsConfig({ columns: [], colors: {} });
} }
}; };
@ -99,66 +75,108 @@ const RealtimeTableContent = ({ formType, formId, sheetName }) => {
loadConfig(); loadConfig();
}, [formType, sheetName]); }, [formType, sheetName]);
// Логинимся в WebSocket // Подписка на обновления от других пользователей
useEffect(() => { useEffect(() => {
if (isConnected && userId) { const unsubscribe = subscribeToCellUpdates((updatedCells) => {
const token = localStorage.getItem('access_token'); console.log('Received updates from other users:', updatedCells);
if (token) {
login(token); 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
};
} }
} return newData;
}, [isConnected, userId, login]); });
// Освобождаем блокировки при размонтировании // Отправляем через WebSocket
useEffect(() => { return await contextUpdateCell(rowId, columnId, value);
return () => { }, [contextUpdateCell, setData]);
releaseAllLocks();
};
}, [releaseAllLocks]);
// Обработчик сохранения ячейки (теперь только для оптимистичного обновления UI) // Создание колонок с интеграцией WebSocket
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
const columns = useMemo(() => { const columns = useMemo(() => {
if (!columnsConfig || !columnsConfig.columns) return []; if (!columnsConfig || !columnsConfig.columns) return [];
try { try {
// Оборачиваем Cell компонент для передачи userId
const CellWithProps = (props) => (
<Cell
{...props}
userId={userId}
onSave={handleSaveCell}
/>
);
return getTableColumns({ return getTableColumns({
Cell: CellWithProps, Cell,
EditCell, EditCell,
columnsConfig columnsConfig,
onCellUpdate: handleUpdateCell, // Передаем функцию обновления
onCellUpdateError: (rowId, columnId, error) => {
console.error(`Error updating cell ${rowId}_${columnId}:`, error);
// Можно добавить Toast уведомление
}
}); });
} catch (error) { } catch (error) {
console.error('Error creating columns:', error); console.error('Error creating columns:', error);
return []; return [];
} }
}, [columnsConfig, userId, data]); }, [columnsConfig, handleUpdateCell]);
// Конфигурация таблицы (без изменений) // Метаданные для таблицы
const tableMeta = useMemo(() => ({
updatingCells,
setUpdatingCell,
updateCell: handleUpdateCell,
}), [updatingCells, setUpdatingCell]);
// Конфигурация таблицы
const tableConfig = useMemo( const tableConfig = useMemo(
() => ({ () => ({
...BASE_TABLE_CONFIG, ...BASE_TABLE_CONFIG,
columns, columns,
data, data: data || [],
columnVirtualizerInstanceRef, columnVirtualizerInstanceRef,
enableRowVirtualization: false, enableRowVirtualization: false,
onColumnSizingChange: setColumnSizing, onColumnSizingChange: setColumnSizing,
@ -171,6 +189,7 @@ const RealtimeTableContent = ({ formType, formId, sheetName }) => {
globalFilter, globalFilter,
showColumnFilters, showColumnFilters,
}, },
meta: tableMeta,
muiTableHeadCellProps: ({ column }) => ({ muiTableHeadCellProps: ({ column }) => ({
sx: { sx: {
width: column.getSize(), width: column.getSize(),
@ -224,41 +243,48 @@ const RealtimeTableContent = ({ formType, formId, sheetName }) => {
globalFilter, globalFilter,
showColumnFilters, showColumnFilters,
containerRef, containerRef,
tableMeta,
], ],
); );
const table = useMaterialReactTable(tableConfig); const table = useMaterialReactTable(tableConfig);
// Показываем индикатор загрузки // Индикатор подключения WebSocket
if (isDataLoading) { const ConnectionStatus = () => (
return ( <div style={{
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px"> position: 'fixed',
<CircularProgress /> bottom: 10,
<Typography sx={{ ml: 2 }}>Загрузка данных...</Typography> right: 10,
</Box> padding: '4px 8px',
); borderRadius: '4px',
} background: isConnected && isAuthenticated ? '#4caf50' : '#f44336',
color: 'white',
fontSize: '12px',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
{console.log(isConnected ,isAuthenticated )}
<span style={{
display: 'inline-block',
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor: 'white',
}} />
{isConnected && isAuthenticated ? '● Connected' : '○ Disconnected'}
{wsError && <div style={{ fontSize: '10px', marginLeft: '8px' }}>{wsError}</div>}
</div>
);
// Показываем ошибку подключения if (!data) {
if (!isConnected) { return <div>Loading...</div>;
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<CircularProgress />
<Typography sx={{ ml: 2 }}>Подключение к серверу...</Typography>
</Box>
);
}
if (wsError) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="400px">
<Typography color="error">Ошибка подключения: {wsError}</Typography>
</Box>
);
} }
return ( return (
<> <>
<ConnectionStatus />
<SettingsPanel <SettingsPanel
selectedColumn={selectedColumn} selectedColumn={selectedColumn}
table={table} table={table}
@ -303,34 +329,4 @@ const RealtimeTableContent = ({ formType, formId, sheetName }) => {
); );
}; };
// Основной компонент с провайдером 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 (
<RealtimeProvider
formId={formId}
sheetName={sheetName}
direction={direction}
userId={null}
>
<RealtimeTableContent
formType={formType}
formId={formId}
sheetName={sheetName}
/>
</RealtimeProvider>
);
};
export default React.memo(RealtimeTable); export default React.memo(RealtimeTable);

View File

@ -1,175 +1,295 @@
import React, { createContext, useContext, useCallback, useRef, useState } from 'react'; import React, {
import { useWebSocket } from '../hooks/useWebSocket'; createContext,
import { useCellLocks } from '../hooks/useCellLocks'; useContext,
import { Config } from '../../../conf/config'; useCallback,
useRef,
useState,
useEffect,
} from "react";
import { useWebSocket } from "../hooks/useWebSocket";
import { useCellLocks } from "../hooks/useCellLocks";
const RealtimeContext = createContext(null); const RealtimeContext = createContext(null);
export const useRealtime = () => { export const useRealtime = () => {
const context = useContext(RealtimeContext); const context = useContext(RealtimeContext);
if (!context) { if (!context) {
throw new Error('useRealtime must be used within RealtimeProvider'); throw new Error("useRealtime must be used within RealtimeProvider");
} }
return context; 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()); 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 { const {
isCellLocked, isCellLocked,
tryLockCell, tryLockCell,
unlockCell, unlockCell,
getCellLockInfo, getCellLockInfo,
releaseAllLocks releaseAllLocks,
} = useCellLocks(userId); } = useCellLocks(null);
const onCellUpdateRef = useRef(null); const onCellUpdateRef = useRef(null);
const onRowAddRef = useRef(null); const onRowAddRef = useRef(null);
const onRowDeleteRef = useRef(null); const onRowDeleteRef = useRef(null);
const onErrorRef = useRef(null); const onErrorRef = useRef(null);
const onAuthSuccessRef = useRef(null);
const authAttemptedRef = useRef(false);
const reconnectTimerRef = useRef(null);
function handleMessage(data) { // Функция для получения токена из localStorage
// Обработка ошибок от бэкенда const getAccessToken = useCallback(() => {
if (data.error) { try {
onErrorRef.current?.(data.error); const token = localStorage.getItem("access_token");
return;
}
// Ваш бэкенд возвращает result после успешной операции if (!token) {
switch (data.event) { console.warn("No access token found in localStorage");
case 'cell_updated': return null;
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
} }
};
// Сохраняем pending update для оптимистичного UI return token;
const updateKey = `${rowId}_${columnId}`; } catch (error) {
setPendingUpdates(prev => new Map(prev).set(updateKey, { value, timestamp: Date.now() })); console.error("Error reading token from localStorage:", error);
return null;
}
}, []);
const sent = sendMessage(message); const sendLogin = useCallback(() => {
const token = getAccessToken();
if (!sent) { if (!token) {
// Откатываем блокировку если сообщение не отправлено console.error("Cannot login: no token available");
unlockCell(rowId, columnId, userId); onErrorRef.current?.("Токен авторизации не найден");
setPendingUpdates(prev => {
const newMap = new Map(prev);
newMap.delete(updateKey);
return newMap;
});
return false; return false;
} }
return true; console.log("Sending login message with token");
}, [sendMessage, tryLockCell, unlockCell, getCellLockInfo, userId]); const loginMessage = {
event: "user_login",
const addRow = useCallback(async (rowData) => { data: { token },
const message = {
event: 'row_added',
data: rowData
}; };
return sendMessage(message); return sendMessage(loginMessage);
}, [sendMessage]); }, [getAccessToken, sendMessage]);
const deleteRow = useCallback(async (rowId) => { useEffect(() => {
const message = { if (isConnected && !authAttemptedRef.current) {
event: 'row_deleted', authAttemptedRef.current = true;
data: { row_id: rowId } console.log("WebSocket connected, sending login...");
}; setTimeout(() => {
sendLogin();
}, 100);
}
}, [isConnected, sendLogin]);
return sendMessage(message); useEffect(() => {
}, [sendMessage]); 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) => { const subscribeToCellUpdates = useCallback((callback) => {
onCellUpdateRef.current = callback; onCellUpdateRef.current = callback;
return () => { onCellUpdateRef.current = null; }; return () => {
onCellUpdateRef.current = null;
};
}, []); }, []);
const subscribeToRowAdds = useCallback((callback) => { const subscribeToRowAdds = useCallback((callback) => {
onRowAddRef.current = callback; onRowAddRef.current = callback;
return () => { onRowAddRef.current = null; }; return () => {
onRowAddRef.current = null;
};
}, []); }, []);
const subscribeToRowDeletes = useCallback((callback) => { const subscribeToRowDeletes = useCallback((callback) => {
onRowDeleteRef.current = callback; onRowDeleteRef.current = callback;
return () => { onRowDeleteRef.current = null; }; return () => {
onRowDeleteRef.current = null;
};
}, []); }, []);
const subscribeToErrors = useCallback((callback) => { const subscribeToErrors = useCallback((callback) => {
onErrorRef.current = callback; onErrorRef.current = callback;
return () => { onErrorRef.current = null; }; return () => {
onErrorRef.current = null;
};
}, []); }, []);
const loginUser = useCallback((token) => { const subscribeToAuthSuccess = useCallback((callback) => {
login(token); onAuthSuccessRef.current = callback;
}, [login]); return () => {
onAuthSuccessRef.current = null;
};
}, []);
const retryLogin = useCallback(() => {
if (isConnected) {
authAttemptedRef.current = false;
sendLogin();
}
}, [isConnected, sendLogin]);
return ( return (
<RealtimeContext.Provider value={{ <RealtimeContext.Provider
isConnected, value={{
error, isConnected,
updateCell, error: lastError,
addRow, updateCell,
deleteRow, addRow,
isCellLocked: (rowId, columnId) => isCellLocked(rowId, columnId, userId), deleteRow,
getCellLockInfo: (rowId, columnId) => getCellLockInfo(rowId, columnId), isCellLocked: (rowId, columnId) =>
tryLockCell: (rowId, columnId) => tryLockCell(rowId, columnId, userId), isCellLocked(rowId, columnId, userId),
subscribeToCellUpdates, getCellLockInfo: (rowId, columnId) => getCellLockInfo(rowId, columnId),
subscribeToRowAdds, tryLockCell: (rowId, columnId) => tryLockCell(rowId, columnId, userId),
subscribeToRowDeletes, subscribeToCellUpdates,
subscribeToErrors, subscribeToRowAdds,
login: loginUser, subscribeToRowDeletes,
releaseAllLocks: () => releaseAllLocks(userId) subscribeToErrors,
}}> subscribeToAuthSuccess,
retryLogin,
releaseAllLocks: () => releaseAllLocks(userId),
}}
>
{children} {children}
</RealtimeContext.Provider> </RealtimeContext.Provider>
); );

View File

@ -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
};
};

View File

@ -34,12 +34,12 @@ export const useWebSocket = (url, onMessage) => {
ws.onerror = (event) => { ws.onerror = (event) => {
console.error('WebSocket error:', event); console.error('WebSocket error:', event);
// Не закрываем соединение при ошибке, просто логируем console.error('WebSocket readyState:', ws.readyState);
setError('Connection error occurred'); setError('Connection error occurred');
}; };
ws.onclose = (event) => { ws.onclose = (event) => {
console.log('WebSocket closed:', event.code, event.reason); console.log('WebSocket closed:', event.code, event.reason, 'Clean:', event.wasClean);
setIsConnected(false); setIsConnected(false);
// Не переподключаемся если закрыто intentionally или с ошибкой авторизации // Не переподключаемся если закрыто intentionally или с ошибкой авторизации

View File

@ -3,9 +3,18 @@ import { createPortal } from 'react-dom';
import { columns as mockColumns } from '../../mocks/mockTable'; import { columns as mockColumns } from '../../mocks/mockTable';
import { config } from './constants/FORM_2/AHR'; 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 ref = useRef(null);
const [refTbody, setRefTbody] = useState(false); const [refTbody, setRefTbody] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const isDisabled = false; const isDisabled = false;
useEffect(() => { 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 ( return (
<> <>
<div <div
@ -24,54 +87,87 @@ function EditCellPortal({ cell, table, EditCell, onChange }) {
height: '100%', height: '100%',
}} }}
/> />
{refTbody && {refTbody && !isSaving &&
createPortal( createPortal(
<EditCell <EditCell
refCell={ref} refCell={ref}
cell={cell} cell={cell}
value={cell.getValue()} value={cell.getValue()}
// onChange={setNewValue} disabled={isDisabled || isSaving}
disabled={isDisabled} onChange={handleChange}
onChange={(val) => {
onChange(val);
table.setEditingCell(null); // Закрываем после сохранения
}}
/>, />,
refTbody, refTbody,
)} )}
{isSaving && (
createPortal(
<div style={{
position: 'absolute',
background: 'rgba(0,0,0,0.5)',
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
zIndex: 1000
}}>
Saving...
</div>,
refTbody
)
)}
</> </>
); );
} }
export const getTableColumns = ({ Cell, EditCell, columnsConfig }) => { export const getTableColumns = ({
Cell,
EditCell,
columnsConfig,
onCellUpdateError
}) => {
const columnColors = { ...columnsConfig.colors }; const columnColors = { ...columnsConfig.colors };
const columns = [...columnsConfig.columns]; const columns = [...columnsConfig.columns];
const processColumns = (columns, EditCell, Cell) => { const processColumns = (columns, EditCell, Cell) => {
columns.forEach((col) => { columns.forEach((col) => {
col.Cell = ({ cell, table, column, row }) => ( col.Cell = ({ cell, table, column, row }) => (
<> <Cell
<Cell row={row}
cell={cell} column={column}
globalFilter={ cell={cell}
table.getState().globalFilter || column.getFilterValue() globalFilter={
} table.getState().globalFilter || column.getFilterValue()
backgroundColor={columnColors[column.id].color_type[row.original.row_type]} }
color={columnColors[column.id].color_type['COLOR']} backgroundColor={columnColors[column.id]?.color_type?.[row.original?.row_type || row.row_type]}
/> color={columnColors[column.id]?.color_type?.['COLOR']}
</> isUpdating={table.options.meta?.updatingCells?.[`${row.id}_${column.id}`]}
/>
); );
col.Edit = ({ cell, table, row, column }) => ( col.Edit = ({ cell, table, row, column }) => (
<EditCellPortal <EditCellPortal
cell={cell} cell={cell}
table={table} table={table}
EditCell={EditCell} EditCell={EditCell}
onChange={(value) => { onChange={(value) => {
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}
/> />
); );

View File

@ -5,6 +5,8 @@ import RealtimeTable from '../components/RealtimeTable';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { } from '../components/RealtimeTable/' import { } from '../components/RealtimeTable/'
import { RealtimeProvider } from '../components/RealtimeTable/contexts/RealtimeContext';
import { useAuth } from '../app/context/AuthProvider';
const TableInfo = () => { const TableInfo = () => {
const portalContent = ( const portalContent = (
@ -23,10 +25,21 @@ const TableInfo = () => {
export default function NewTablePage() { export default function NewTablePage() {
const { formId, formType, sheetName } = useParams() const { formId, formType, sheetName } = useParams()
const { user } = useAuth();
return ( return (
<> <>
<RealtimeTable formType={formType} formId={formId} sheetName={sheetName} /> {user && (
<RealtimeProvider
formId={formId}
sheetName={sheetName}
userId={user.id}
>
<RealtimeTable formType={formType} formId={formId} sheetName={sheetName} />
</RealtimeProvider>
)}
</> </>
); );
} }

View File

@ -2,6 +2,7 @@ import { createPortal } from 'react-dom';
import { TaskInfoContainer } from '../components/common/SwitchFormTask/SwitchFormTask.style'; import { TaskInfoContainer } from '../components/common/SwitchFormTask/SwitchFormTask.style';
import RealtimeTable from '../components/RealtimeTable'; import RealtimeTable from '../components/RealtimeTable';
import { RealtimeProvider } from '../components/RealtimeTable/contexts/RealtimeContext';
const TableInfo = () => { const TableInfo = () => {
const portalContent = ( const portalContent = (
@ -21,7 +22,9 @@ const TableInfo = () => {
export default function NewTablePage() { export default function NewTablePage() {
return ( return (
<> <>
<RealtimeTable />
<RealtimeTable
/>
</> </>
); );
} }