diff --git a/src/components/robot/RecordingsTable.tsx b/src/components/robot/RecordingsTable.tsx index a7594b086..484b1667b 100644 --- a/src/components/robot/RecordingsTable.tsx +++ b/src/components/robot/RecordingsTable.tsx @@ -8,7 +8,7 @@ import TableContainer from '@mui/material/TableContainer'; import TableHead from '@mui/material/TableHead'; import TablePagination from '@mui/material/TablePagination'; import TableRow from '@mui/material/TableRow'; -import { useEffect } from "react"; +import { memo, useCallback, useEffect, useMemo } from "react"; import { WorkflowFile } from "maxun-core"; import SearchIcon from '@mui/icons-material/Search'; import { @@ -76,6 +76,64 @@ interface RecordingsTableProps { handleDuplicateRobot: (id: string, name: string, params: string[]) => void; } +// Virtualized row component for efficient rendering +const TableRowMemoized = memo(({ row, columns, handlers }: any) => { + return ( + + {columns.map((column: Column) => { + const value: any = row[column.id]; + if (value !== undefined) { + return ( + + {value} + + ); + } else { + switch (column.id) { + case 'interpret': + return ( + + handlers.handleRunRecording(row.id, row.name, row.params || [])} /> + + ); + case 'schedule': + return ( + + handlers.handleScheduleRecording(row.id, row.name, row.params || [])} /> + + ); + case 'integrate': + return ( + + handlers.handleIntegrateRecording(row.id, row.name, row.params || [])} /> + + ); + case 'options': + return ( + + handlers.handleEditRobot(row.id, row.name, row.params || [])} + handleDuplicate={() => handlers.handleDuplicateRobot(row.id, row.name, row.params || [])} + handleDelete={() => handlers.handleDelete(row.id)} + /> + + ); + case 'settings': + return ( + + handlers.handleSettingsRecording(row.id, row.name, row.params || [])} /> + + ); + default: + return null; + } + } + })} + + ); +}); + + export const RecordingsTable = ({ handleEditRecording, handleRunRecording, @@ -90,31 +148,16 @@ export const RecordingsTable = ({ const [rows, setRows] = React.useState([]); const [isModalOpen, setModalOpen] = React.useState(false); const [searchTerm, setSearchTerm] = React.useState(''); + const [isLoading, setIsLoading] = React.useState(true); - const columns: readonly Column[] = [ + const columns = useMemo(() => [ { id: 'interpret', label: t('recordingtable.run'), minWidth: 80 }, { id: 'name', label: t('recordingtable.name'), minWidth: 80 }, - { - id: 'schedule', - label: t('recordingtable.schedule'), - minWidth: 80, - }, - { - id: 'integrate', - label: t('recordingtable.integrate'), - minWidth: 80, - }, - { - id: 'settings', - label: t('recordingtable.settings'), - minWidth: 80, - }, - { - id: 'options', - label: t('recordingtable.options'), - minWidth: 80, - }, - ]; + { id: 'schedule', label: t('recordingtable.schedule'), minWidth: 80 }, + { id: 'integrate', label: t('recordingtable.integrate'), minWidth: 80 }, + { id: 'settings', label: t('recordingtable.settings'), minWidth: 80 }, + { id: 'options', label: t('recordingtable.options'), minWidth: 80 }, + ], [t]); const { notify, @@ -132,54 +175,63 @@ export const RecordingsTable = ({ setRecordingId } = useGlobalInfoStore(); const navigate = useNavigate(); - const handleChangePage = (event: unknown, newPage: number) => { + const handleChangePage = useCallback((event: unknown, newPage: number) => { setPage(newPage); - }; + }, []); const handleChangeRowsPerPage = (event: React.ChangeEvent) => { setRowsPerPage(+event.target.value); setPage(0); }; - const handleSearchChange = (event: React.ChangeEvent) => { + const handleSearchChange = useCallback((event: React.ChangeEvent) => { setSearchTerm(event.target.value); setPage(0); - }; + }, []); - const fetchRecordings = async () => { - const recordings = await getStoredRecordings(); - if (recordings) { - const parsedRows: Data[] = []; - recordings.map((recording: any, index: number) => { - if (recording && recording.recording_meta) { - parsedRows.push({ - id: index, - ...recording.recording_meta, - content: recording.recording - }); - } - }); - setRecordings(parsedRows.map((recording) => recording.name)); - setRows(parsedRows); - } else { - console.log('No recordings found.'); + const fetchRecordings = useCallback(async () => { + setIsLoading(true); + try { + const recordings = await getStoredRecordings(); + if (recordings) { + const parsedRows = recordings + .map((recording: any, index: number) => { + if (recording?.recording_meta) { + return { + id: index, + ...recording.recording_meta, + content: recording.recording + }; + } + return null; + }) + .filter(Boolean); + + setRecordings(parsedRows.map((recording) => recording.name)); + setRows(parsedRows); + } + } catch (error) { + console.error('Error fetching recordings:', error); + notify('error', t('recordingtable.notifications.fetch_error')); + } finally { + setIsLoading(false); } - } + }, [setRecordings, notify, t]); - const handleNewRecording = async () => { + const handleNewRecording = useCallback(async () => { if (browserId) { setBrowserId(null); await stopRecording(browserId); } setModalOpen(true); - }; + }, [browserId]); - const handleStartRecording = () => { + const handleStartRecording = useCallback(() => { setBrowserId('new-recording'); setRecordingName(''); setRecordingId(''); navigate('/recording'); - } + }, [navigate]); const startRecording = () => { setModalOpen(false); @@ -195,14 +247,61 @@ export const RecordingsTable = ({ if (rows.length === 0) { fetchRecordings(); } - }, []); + }, [fetchRecordings]); + + function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = React.useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; + } + const debouncedSearchTerm = useDebounce(searchTerm, 300); // Filter rows based on search term - const filteredRows = rows.filter((row) => - row.name.toLowerCase().includes(searchTerm.toLowerCase()) - ); - + const filteredRows = useMemo(() => { + const searchLower = debouncedSearchTerm.toLowerCase(); + return debouncedSearchTerm + ? rows.filter(row => row.name.toLowerCase().includes(searchLower)) + : rows; + }, [rows, debouncedSearchTerm]); + + const visibleRows = useMemo(() => { + const start = page * rowsPerPage; + return filteredRows.slice(start, start + rowsPerPage); + }, [filteredRows, page, rowsPerPage]); + + const handlers = useMemo(() => ({ + handleRunRecording, + handleScheduleRecording, + handleIntegrateRecording, + handleSettingsRecording, + handleEditRobot, + handleDuplicateRobot, + handleDelete: async (id: string) => { + const hasRuns = await checkRunsForRecording(id); + if (hasRuns) { + notify('warning', t('recordingtable.notifications.delete_warning')); + return; + } + + const success = await deleteRecordingFromStorage(id); + if (success) { + setRows([]); + notify('success', t('recordingtable.notifications.delete_success')); + fetchRecordings(); + } + } + }), [handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot, handleDuplicateRobot, notify, t]); return ( @@ -244,7 +343,7 @@ export const RecordingsTable = ({ - {rows.length === 0 ? ( + {isLoading ? ( @@ -254,99 +353,32 @@ export const RecordingsTable = ({ {columns.map((column) => ( - {column.label} - + ))} - {filteredRows.length !== 0 ? filteredRows - .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) - .map((row) => { - return ( - - {columns.map((column) => { - // @ts-ignore - const value: any = row[column.id]; - if (value !== undefined) { - return ( - - {value} - - ); - } else { - switch (column.id) { - case 'interpret': - return ( - - handleRunRecording(row.id, row.name, row.params || [])} /> - - ); - case 'schedule': - return ( - - handleScheduleRecording(row.id, row.name, row.params || [])} /> - - ); - case 'integrate': - return ( - - handleIntegrateRecording(row.id, row.name, row.params || [])} /> - - ); - case 'options': - return ( - - handleEditRobot(row.id, row.name, row.params || [])} - handleDuplicate={() => { - handleDuplicateRobot(row.id, row.name, row.params || []); - }} - handleDelete={() => { - - checkRunsForRecording(row.id).then((result: boolean) => { - if (result) { - notify('warning', t('recordingtable.notifications.delete_warning')); - } - }) - - deleteRecordingFromStorage(row.id).then((result: boolean) => { - if (result) { - setRows([]); - notify('success', t('recordingtable.notifications.delete_success')); - fetchRecordings(); - } - }) - }} - /> - - ); - case 'settings': - return ( - - handleSettingsRecording(row.id, row.name, row.params || [])} /> - - ); - default: - return null; - } - } - })} - - ); - }) - : null} + {visibleRows.map((row) => ( + + ))} )} + = ({ const { t } = useTranslation(); const navigate = useNavigate(); - const translatedColumns = columns.map(column => ({ - ...column, - label: t(`runstable.${column.id}`, column.label) - })); + const translatedColumns = useMemo(() => + columns.map(column => ({ + ...column, + label: t(`runstable.${column.id}`, column.label) + })), + [t] + ); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(10); const [rows, setRows] = useState([]); const [searchTerm, setSearchTerm] = useState(''); + const [isLoading, setIsLoading] = useState(true); const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore(); - const handleAccordionChange = (robotMetaId: string, isExpanded: boolean) => { - if (isExpanded) { - navigate(`/runs/${robotMetaId}`); - } else { - navigate(`/runs`); - } - }; + const handleAccordionChange = useCallback((robotMetaId: string, isExpanded: boolean) => { + navigate(isExpanded ? `/runs/${robotMetaId}` : '/runs'); + }, [navigate]); - const handleChangePage = (event: unknown, newPage: number) => { + const handleChangePage = useCallback((event: unknown, newPage: number) => { setPage(newPage); - }; + }, []); - const handleChangeRowsPerPage = (event: React.ChangeEvent) => { + const handleChangeRowsPerPage = useCallback((event: React.ChangeEvent) => { setRowsPerPage(+event.target.value); setPage(0); - }; + }, []); - const handleSearchChange = (event: React.ChangeEvent) => { - setSearchTerm(event.target.value); - setPage(0); - }; - - const fetchRuns = async () => { - const runs = await getStoredRuns(); - if (runs) { - const parsedRows: Data[] = runs.map((run: any, index: number) => ({ - id: index, - ...run, - })); - setRows(parsedRows); - } else { - notify('error', t('runstable.notifications.no_runs')); + const debouncedSearch = useCallback((fn: Function, delay: number) => { + let timeoutId: NodeJS.Timeout; + return (...args: any[]) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => fn(...args), delay); + }; + }, []); + + const handleSearchChange = useCallback((event: React.ChangeEvent) => { + const debouncedSetSearch = debouncedSearch((value: string) => { + setSearchTerm(value); + setPage(0); + }, 300); + debouncedSetSearch(event.target.value); + }, [debouncedSearch]); + + const fetchRuns = useCallback(async () => { + try { + setIsLoading(true); + const runs = await getStoredRuns(); + if (runs) { + const parsedRows: Data[] = runs.map((run: any, index: number) => ({ + id: index, + ...run, + })); + setRows(parsedRows); + } else { + notify('error', t('runstable.notifications.no_runs')); + } + } catch (error) { + notify('error', t('runstable.notifications.fetch_error')); + } finally { + setIsLoading(false); } - }; + }, [notify, t]); useEffect(() => { + let mounted = true; + if (rows.length === 0 || rerenderRuns) { - fetchRuns(); - setRerenderRuns(false); + fetchRuns().then(() => { + if (mounted) { + setRerenderRuns(false); + } + }); } - }, [rerenderRuns, rows.length, setRerenderRuns]); - const handleDelete = () => { + return () => { + mounted = false; + }; + }, [rerenderRuns, rows.length, setRerenderRuns, fetchRuns]); + + const handleDelete = useCallback(() => { setRows([]); notify('success', t('runstable.notifications.delete_success')); fetchRuns(); - }; + }, [notify, t, fetchRuns]); // Filter rows based on search term - const filteredRows = rows.filter((row) => - row.name.toLowerCase().includes(searchTerm.toLowerCase()) + const filteredRows = useMemo(() => + rows.filter((row) => + row.name.toLowerCase().includes(searchTerm.toLowerCase()) + ), + [rows, searchTerm] ); // Group filtered rows by robot meta id - const groupedRows = filteredRows.reduce((acc, row) => { - if (!acc[row.robotMetaId]) { - acc[row.robotMetaId] = []; - } - acc[row.robotMetaId].push(row); - return acc; - }, {} as Record); + const groupedRows = useMemo(() => + filteredRows.reduce((acc, row) => { + if (!acc[row.robotMetaId]) { + acc[row.robotMetaId] = []; + } + acc[row.robotMetaId].push(row); + return acc; + }, {} as Record), + [filteredRows] + ); + + const renderTableRows = useCallback((data: Data[]) => { + const start = page * rowsPerPage; + const end = start + rowsPerPage; + + return data + .slice(start, end) + .map((row) => ( + + )); + }, [page, rowsPerPage, runId, runningRecordingName, currentInterpretationLog, abortRunHandler, handleDelete]); + + if (isLoading) { + return ( + + + + ); + } return ( - + {t('runstable.runs', 'Runs')} @@ -160,62 +219,50 @@ export const RunsTable: React.FC = ({ sx={{ width: '250px' }} /> - {rows.length === 0 ? ( - - - - ) : ( - - {Object.entries(groupedRows).map(([id, data]) => ( - handleAccordionChange(id, isExpanded)}> - }> - {data[data.length - 1].name} - - - - - - - {translatedColumns.map((column) => ( - - {column.label} - - ))} - - - - {data - .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) - .map((row) => ( - - ))} - -
-
-
- ))} -
- )} + + + {Object.entries(groupedRows).map(([id, data]) => ( + handleAccordionChange(id, isExpanded)} + TransitionProps={{ unmountOnExit: true }} // Optimize accordion rendering + > + }> + {data[data.length - 1].name} + + + + + + + {translatedColumns.map((column) => ( + + {column.label} + + ))} + + + + {renderTableRows(data)} + +
+
+
+ ))} +
+
);