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 (
<>
-
+
+
>
);
}