531 lines
13 KiB
JavaScript
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>
|
|
);
|
|
};
|