531 lines
13 KiB
JavaScript

import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { toast } from 'react-toastify';
import { useWebSocket } from '../hooks/useWebSocket';
import { getRowId } from '../utils/rowUtils';
const RealtimeActionsContext = createContext(null);
const RealtimeConnectionContext = createContext(null);
const RealtimeVspOptionsContext = createContext(null);
const LockedCellsStoreContext = createContext(null);
const getColumnRequestKey = (column) => {
const columnKey = column?.columnDef?.id ?? column?.id;
if (columnKey === null || columnKey === undefined) return '';
const normalizedColumnKey = String(columnKey);
return normalizedColumnKey.startsWith('data.') ? normalizedColumnKey.slice(5) : normalizedColumnKey;
};
const useRequiredContext = (context, hookName) => {
const value = useContext(context);
if (value === null) {
throw new Error(`${hookName} must be used within RealtimeProvider`);
}
return value;
};
const createLockedCellsStore = () => {
let cells = new Set();
const listenersByCell = new Map();
const subscribeCell = (cellKey, listener) => {
const listeners = listenersByCell.get(cellKey) || new Set();
listeners.add(listener);
listenersByCell.set(cellKey, listeners);
return () => {
listeners.delete(listener);
if (listeners.size === 0) listenersByCell.delete(cellKey);
};
};
const setCells = (nextValue) => {
const currentCells = [...cells];
const resolvedValue = typeof nextValue === 'function' ? nextValue(currentCells) : nextValue;
const nextCells = new Set(resolvedValue || []);
const changedCells = new Set([
...[...cells].filter((cellKey) => !nextCells.has(cellKey)),
...[...nextCells].filter((cellKey) => !cells.has(cellKey)),
]);
if (changedCells.size === 0) return;
cells = nextCells;
changedCells.forEach((cellKey) => {
listenersByCell.get(cellKey)?.forEach((listener) => listener());
});
};
return {
subscribeCell,
setCells,
hasCell: (cellKey) => cells.has(cellKey),
getCells: () => [...cells],
};
};
export const useRealtimeActions = () => useRequiredContext(RealtimeActionsContext, 'useRealtimeActions');
export const useRealtimeConnection = () => useRequiredContext(RealtimeConnectionContext, 'useRealtimeConnection');
export const useVspOptions = () => useRequiredContext(RealtimeVspOptionsContext, 'useVspOptions');
export const useCellLock = (cellKey) => {
const store = useRequiredContext(LockedCellsStoreContext, 'useCellLock');
const subscribe = useCallback((listener) => store.subscribeCell(cellKey, listener), [cellKey, store]);
const getSnapshot = useCallback(() => store.hasCell(cellKey), [cellKey, store]);
return useSyncExternalStore(subscribe, getSnapshot, () => false);
};
export const useRealtime = () => {
const actions = useRealtimeActions();
const connection = useRealtimeConnection();
const vspOptions = useVspOptions();
return { ...actions, ...connection, vspOptions };
};
export const RealtimeProvider = ({ children, formId, sheetName, direction, year, isProject = false }) => {
// Формируем URL (кастомные секреты на фронте пока недоступны, делаем через REACT_APP_ROOT_PATH)
const wsUrl = `${(
process.env.REACT_APP_API_URL || process.env.REACT_APP_API_URL || process.env.REACT_APP_ROOT_PATH || 'ws://localhost:8000'
).replace(/^http/, 'ws')}${
!isProject
? `/api/v1/ws/form/${formId}/sheet/${sheetName}${direction ? `?direction=${direction}` : ''}`
: `/api/v1/ws/projects/${formId}/report/${year}/${sheetName}`
}`;
const [vspOptions, setVspOptions] = useState([]);
const lockedCellsStoreRef = useRef(null);
if (lockedCellsStoreRef.current === null) {
lockedCellsStoreRef.current = createLockedCellsStore();
}
const lockedCellsStore = lockedCellsStoreRef.current;
const handleMessage = useCallback((data) => {
// Обработка ошибок
if (data.error) {
console.log(data.error.message);
toast.error(`Server error:${data.error.message}` || '');
onErrorRef.current?.(data.error);
return;
}
switch (data.event) {
case 'cell_edit_start':
console.log('Cell edit started:', data);
if (onCellEditStartRef.current) {
onCellEditStartRef.current(data);
}
break;
case 'cell_edit_end':
console.log('Cell edit ended:', data);
if (onCellEditEndRef.current) {
onCellEditEndRef.current(data);
}
break;
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);
}
break;
case 'row_deleted':
if (data.result && onRowDeleteRef.current) {
onRowDeleteRef.current(data);
}
break;
case 'program_added':
console.log('Program added:', data);
if (data.result && onProgramAddRef.current) {
onProgramAddRef.current(data);
}
break;
case 'project_added':
console.log('Project added:', data);
if (data.result && onProjectAddRef.current) {
onProjectAddRef.current(data);
}
break;
default:
console.log('Unknown event:', data.event);
}
}, []);
const { isConnectedRef, isConnected, sendMessage } = useWebSocket(wsUrl, handleMessage);
const onCellUpdateRef = useRef(null);
const onRowAddRef = useRef(null);
const onRowDeleteRef = useRef(null);
const onProgramAddRef = useRef(null);
const onProjectAddRef = useRef(null);
const onErrorRef = useRef(null);
const onAuthSuccessRef = useRef(null);
const onCellEditStartRef = useRef(null);
const onCellEditEndRef = useRef(null);
const authAttemptedRef = useRef(false);
// Функция для получения токена из localStorage
const getAccessToken = useCallback(() => {
try {
const token = localStorage.getItem('access_token');
if (!token) {
console.warn('No access token found in localStorage');
return null;
}
return token;
} catch (error) {
console.error('Error reading token from localStorage:', error);
return null;
}
}, []);
const sendLogin = useCallback(() => {
const token = getAccessToken();
if (!token) {
console.error('Cannot login: no token available');
onErrorRef.current?.('Токен авторизации не найден');
return false;
}
const loginMessage = {
event: 'user_login',
data: { token },
};
return sendMessage(loginMessage);
}, [getAccessToken, sendMessage]);
useEffect(() => {
if (isConnected && !authAttemptedRef.current) {
authAttemptedRef.current = true;
console.log('WebSocket connected, sending login...');
const timeoutId = setTimeout(() => {
sendLogin();
}, 100);
return () => clearTimeout(timeoutId);
}
}, [isConnected, sendLogin]);
useEffect(() => {
if (!isConnected) {
authAttemptedRef.current = false;
}
}, [isConnected]);
const addCommonField = useCallback(
(message) => {
if (isProject) {
message.report_type = sheetName;
message.project_id = formId;
message.year = year;
} else {
message.sheet = sheetName;
message.form_id = formId;
message.direction = direction;
}
return message;
},
[direction, formId, isProject, sheetName, year],
);
// Функция для начала редактирования ячейки
const startEditing = useCallback(
(row, column) => {
if (!isConnectedRef.current) {
onErrorRef.current?.('WebSocket не подключен');
return false;
}
const lineId = row.id || null;
const columnKey = getColumnRequestKey(column);
const message = {
event: 'cell_edit_start',
data: {
line_id: lineId,
column: columnKey,
},
};
const sent = sendMessage(message);
if (!sent) {
return false;
}
return true;
},
[isConnectedRef, sendMessage],
);
// Функция для завершения редактирования ячейки
const endEditing = useCallback(
(row, column) => {
if (!isConnectedRef.current) {
onErrorRef.current?.('WebSocket не подключен');
return false;
}
const lineId = row.id || null;
const columnKey = getColumnRequestKey(column);
const message = {
event: 'cell_edit_end',
data: {
line_id: lineId,
column: columnKey,
},
};
const sent = sendMessage(message);
if (!sent) {
return false;
}
return true;
},
[isConnectedRef, sendMessage],
);
const updateCell = useCallback(
(row, column, value) => {
if (!isConnectedRef.current) {
onErrorRef.current?.('WebSocket не подключен');
return false;
}
const lineId = getRowId(row);
const columnKey = getColumnRequestKey(column);
const message = {
event: 'cell_updated',
data: {
line_id: lineId,
line_id_code: row.id,
column: columnKey,
value: value,
},
};
const sent = sendMessage(message);
if (!sent) {
return false;
}
return true;
},
[isConnectedRef, sendMessage],
);
const addRow = useCallback(
(rowData) => {
if (!isConnectedRef.current) {
onErrorRef.current?.('Нет подключения или авторизации');
return false;
}
const message = {
event: 'row_added',
data: rowData,
};
addCommonField(message);
return sendMessage(message);
},
[addCommonField, isConnectedRef, sendMessage],
);
const addProject = useCallback(
(rowData) => {
if (!isConnectedRef.current) {
onErrorRef.current?.('Нет подключения или авторизации');
return false;
}
const message = {
event: 'project_added',
data: rowData,
};
addCommonField(message);
return sendMessage(message);
},
[addCommonField, isConnectedRef, sendMessage],
);
const addProgram = useCallback(
(rowData) => {
if (!isConnectedRef.current) {
onErrorRef.current?.('Нет подключения или авторизации');
return false;
}
const message = {
event: 'program_added',
data: rowData,
};
addCommonField(message);
return sendMessage(message);
},
[addCommonField, isConnectedRef, sendMessage],
);
const deleteRow = useCallback(
(rowId) => {
if (!isConnectedRef.current) {
onErrorRef.current?.('Нет подключения или авторизации');
return false;
}
const message = {
event: 'row_deleted',
data: { row_id: rowId },
};
addCommonField(message);
return sendMessage(message);
},
[addCommonField, isConnectedRef, sendMessage],
);
const subscribeToCellUpdates = useCallback((callback) => {
onCellUpdateRef.current = callback;
return () => {
onCellUpdateRef.current = null;
};
}, []);
const subscribeToRowAdds = useCallback((callback) => {
onRowAddRef.current = callback;
return () => {
onRowAddRef.current = null;
};
}, []);
const subscribeToRowDeletes = useCallback((callback) => {
onRowDeleteRef.current = callback;
return () => {
onRowDeleteRef.current = null;
};
}, []);
const subscribeToProgramAdds = useCallback((callback) => {
onProgramAddRef.current = callback;
return () => {
onProgramAddRef.current = null;
};
}, []);
const subscribeToProjectAdds = useCallback((callback) => {
onProjectAddRef.current = callback;
return () => {
onProjectAddRef.current = null;
};
}, []);
const subscribeToErrors = useCallback((callback) => {
onErrorRef.current = callback;
return () => {
onErrorRef.current = null;
};
}, []);
const subscribeToAuthSuccess = useCallback((callback) => {
onAuthSuccessRef.current = callback;
return () => {
onAuthSuccessRef.current = null;
};
}, []);
const subscribeToCellEditStart = useCallback((callback) => {
onCellEditStartRef.current = callback;
return () => {
onCellEditStartRef.current = null;
};
}, []);
const subscribeToCellEditEnd = useCallback((callback) => {
onCellEditEndRef.current = callback;
return () => {
onCellEditEndRef.current = null;
};
}, []);
const retryLogin = useCallback(() => {
if (isConnectedRef.current) {
authAttemptedRef.current = false;
sendLogin();
}
}, [isConnectedRef, sendLogin]);
const actions = useMemo(
() => ({
startEditing,
endEditing,
updateCell,
addRow,
addProgram,
addProject,
deleteRow,
subscribeToRowAdds,
subscribeToRowDeletes,
subscribeToProgramAdds,
subscribeToProjectAdds,
subscribeToErrors,
subscribeToAuthSuccess,
subscribeToCellEditStart,
subscribeToCellEditEnd,
subscribeToCellUpdates,
retryLogin,
setLockedCells: lockedCellsStore.setCells,
setVspOptions,
}),
[
addProgram,
addProject,
addRow,
deleteRow,
endEditing,
lockedCellsStore,
retryLogin,
startEditing,
subscribeToAuthSuccess,
subscribeToCellEditEnd,
subscribeToCellEditStart,
subscribeToCellUpdates,
subscribeToErrors,
subscribeToProgramAdds,
subscribeToProjectAdds,
subscribeToRowAdds,
subscribeToRowDeletes,
updateCell,
],
);
const connection = useMemo(() => ({ isConnected }), [isConnected]);
return (
<RealtimeActionsContext.Provider value={actions}>
<RealtimeConnectionContext.Provider value={connection}>
<RealtimeVspOptionsContext.Provider value={vspOptions}>
<LockedCellsStoreContext.Provider value={lockedCellsStore}>{children}</LockedCellsStoreContext.Provider>
</RealtimeVspOptionsContext.Provider>
</RealtimeConnectionContext.Provider>
</RealtimeActionsContext.Provider>
);
};