search-table-scroll #114
@ -110,7 +110,7 @@ const createHighlightedContent = (originalValue, displayValue, searchQueries) =>
|
||||
const cleanQueries = searchQueries.filter(Boolean);
|
||||
if (cleanQueries.length === 0) return displayValue;
|
||||
|
||||
const searchStr = String(originalValue ?? displayValue);
|
||||
const searchStr = String(displayValue ?? originalValue);
|
||||
const escapedQueries = cleanQueries.map((q) => q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
const regex = new RegExp(`(${escapedQueries.join('|')})`, 'gi');
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ import { SelectExpenseItemModal } from './Modals/SelectExpenseItemModal';
|
||||
import { SelectVspModal } from './Modals/SelectVspModal';
|
||||
import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig';
|
||||
import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, getTableBodyCellProps, getTablePaperStyles } from './constants/tableConfig';
|
||||
import { useRealtimeActions } from './contexts/RealtimeContext';
|
||||
import { useRealtimeActions, useVspOptions } from './contexts/RealtimeContext';
|
||||
import { useColumnSettings } from './hooks/useColumnSettings';
|
||||
import { useHeaderPortal } from './hooks/useHeaderPortal';
|
||||
import { useTableScale } from './hooks/useTableScale';
|
||||
@ -39,15 +39,15 @@ const ROW_VIRTUALIZER_OPTIONS = {
|
||||
};
|
||||
|
||||
const getColumnVirtualizerOptions = ({ table }) => {
|
||||
const orderedVisibleColumns = [
|
||||
...table.getLeftVisibleLeafColumns(),
|
||||
...table.getCenterVisibleLeafColumns(),
|
||||
...table.getRightVisibleLeafColumns(),
|
||||
];
|
||||
const orderedVisibleColumns = table.getVisibleLeafColumns();
|
||||
const getColumnSize = (index) => orderedVisibleColumns[index]?.getSize() ?? 150;
|
||||
const scrollPaddingStart = table.getLeftVisibleLeafColumns().reduce((width, column) => width + column.getSize(), 0);
|
||||
const scrollPaddingEnd = table.getRightVisibleLeafColumns().reduce((width, column) => width + column.getSize(), 0);
|
||||
|
||||
return {
|
||||
overscan: 10,
|
||||
scrollPaddingStart,
|
||||
scrollPaddingEnd,
|
||||
estimateSize: getColumnSize,
|
||||
measureElement: (element) => {
|
||||
if (!element) return 150;
|
||||
@ -59,6 +59,18 @@ const getColumnVirtualizerOptions = ({ table }) => {
|
||||
const hasVspDropdown = (columns) =>
|
||||
columns.some((column) => column.editType === 'vsp_dropdown' || (column.columns?.length && hasVspDropdown(column.columns)));
|
||||
|
||||
const normalizeFilterValue = (value) =>
|
||||
String(value ?? '')
|
||||
.trim()
|
||||
.toLocaleLowerCase('ru-RU');
|
||||
|
||||
const doesCellMatchFilter = (rawValue, columnDef, query, vspRegistrationById) => {
|
||||
if (normalizeFilterValue(rawValue).includes(query)) return true;
|
||||
if (columnDef?.editType !== 'vsp_dropdown') return false;
|
||||
|
||||
return vspRegistrationById.get(Number(rawValue))?.includes(query) ?? false;
|
||||
};
|
||||
|
||||
const tableContentStyle = {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
@ -79,6 +91,7 @@ const loadingOverlayStyle = {
|
||||
|
||||
const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
const { user } = useAuth();
|
||||
const vspOptions = useVspOptions();
|
||||
const userRoleId = user?.role_id;
|
||||
const {
|
||||
data,
|
||||
@ -87,6 +100,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
editingCells: dataEditingCells,
|
||||
} = useRealtimeData(formId, sheetName, direction, formType, year);
|
||||
const [globalFilter, setGlobalFilter] = useState('');
|
||||
const [searchMatchIndex, setSearchMatchIndex] = useState(-1);
|
||||
const [activeSearchMatch, setActiveSearchMatch] = useState(null);
|
||||
const [pendingSearchDirection, setPendingSearchDirection] = useState(0);
|
||||
const [showColumnFilters, setShowColumnFilters] = useState(false);
|
||||
const [selectedColumnId, setSelectedColumnId] = useState();
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
@ -120,9 +136,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
|
||||
const handleGlobalFilterChange = useMemo(
|
||||
() =>
|
||||
debounce((value) => {
|
||||
debounce((valueOrUpdater) => {
|
||||
startTransition(() => {
|
||||
setGlobalFilter(value);
|
||||
setGlobalFilter((currentValue) => (typeof valueOrUpdater === 'function' ? valueOrUpdater(currentValue) : valueOrUpdater));
|
||||
});
|
||||
}, 300),
|
||||
[startTransition],
|
||||
@ -300,6 +316,23 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
[handleUpdateCell],
|
||||
);
|
||||
|
||||
const vspRegistrationById = useMemo(
|
||||
() => new Map(vspOptions.map((vsp) => [Number(vsp.id), normalizeFilterValue(vsp.registration_number)])),
|
||||
[vspOptions],
|
||||
);
|
||||
|
||||
const globalFilterFn = useCallback(
|
||||
(row, columnId, filterValue) => {
|
||||
const query = normalizeFilterValue(filterValue);
|
||||
if (!query) return true;
|
||||
|
||||
const rawValue = row.getValue(columnId);
|
||||
const column = row.getAllCells().find((cell) => cell.column.id === columnId)?.column;
|
||||
return doesCellMatchFilter(rawValue, column?.columnDef, query, vspRegistrationById);
|
||||
},
|
||||
[vspRegistrationById],
|
||||
);
|
||||
|
||||
const createTableConfig = ({
|
||||
columns,
|
||||
data,
|
||||
@ -317,8 +350,11 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
setExpanded,
|
||||
editingCell,
|
||||
contextStartEditing,
|
||||
globalFilterFn,
|
||||
activeSearchMatch,
|
||||
}) => ({
|
||||
...BASE_TABLE_CONFIG,
|
||||
globalFilterFn,
|
||||
columns,
|
||||
data,
|
||||
enableRowVirtualization: true,
|
||||
@ -355,7 +391,21 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
muiTableHeadCellProps: {
|
||||
sx: { boxSizing: 'border-box' },
|
||||
},
|
||||
muiTableBodyCellProps: getTableBodyCellProps,
|
||||
muiTableBodyCellProps: (props) => {
|
||||
const cellProps = getTableBodyCellProps(props);
|
||||
const isActiveMatch = activeSearchMatch?.rowId === props.row.id && activeSearchMatch?.columnId === props.column.id;
|
||||
|
||||
return {
|
||||
...cellProps,
|
||||
sx: {
|
||||
...cellProps.sx,
|
||||
...(isActiveMatch && {
|
||||
zIndex: 7,
|
||||
boxShadow: 'inset 0 0 0 3px #1976d2',
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
muiTableHeadProps: {
|
||||
sx: {
|
||||
display: 'table-header-group',
|
||||
@ -406,6 +456,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
setExpanded,
|
||||
editingCell,
|
||||
contextStartEditing,
|
||||
globalFilterFn,
|
||||
activeSearchMatch,
|
||||
}),
|
||||
[
|
||||
columns,
|
||||
@ -422,6 +474,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
setColumnPinning,
|
||||
editingCell,
|
||||
expanded,
|
||||
globalFilterFn,
|
||||
activeSearchMatch,
|
||||
],
|
||||
);
|
||||
|
||||
@ -431,31 +485,108 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
const handleNavigateToColumn = useCallback(
|
||||
(columnId) => {
|
||||
if (!columnId) return;
|
||||
console.log(columnId);
|
||||
console.log(columnId);
|
||||
console.log(columnId);
|
||||
console.log(columnId);
|
||||
|
||||
setSelectedColumnId(columnId);
|
||||
|
||||
const pinnedIds = new Set([...(columnPinning?.left || []), ...(columnPinning?.right || [])]);
|
||||
if (pinnedIds.has(columnId)) return;
|
||||
|
||||
const visibleColumns = table.getVisibleLeafColumns();
|
||||
const columnIndex = visibleColumns.findIndex((column) => column.id === columnId);
|
||||
if (columnIndex < 0) return;
|
||||
|
||||
const columnVirtualizer = columnVirtualizerRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const pinnedIds = new Set(columnPinning?.left || []);
|
||||
if (pinnedIds.has(columnId)) return;
|
||||
|
||||
const centerColumns = table.getVisibleLeafColumns().filter((column) => !pinnedIds.has(column.id));
|
||||
|
||||
let offset = 0;
|
||||
for (const column of centerColumns) {
|
||||
if (column.id === columnId) break;
|
||||
offset += column.getSize();
|
||||
}
|
||||
const leftPinnedSize = table.getLeftVisibleLeafColumns().reduce((width, column) => width + column.getSize(), 0);
|
||||
const calculatedStart = visibleColumns.slice(0, columnIndex).reduce((offset, column) => offset + column.getSize(), 0);
|
||||
const measurement = columnVirtualizer?.measurementsCache?.[columnIndex];
|
||||
const targetStart = measurement?.start ?? calculatedStart;
|
||||
const targetOffset = Math.max(0, targetStart - leftPinnedSize);
|
||||
|
||||
container.scrollTo({
|
||||
left: Math.max(0, offset - 40),
|
||||
behavior: 'smooth',
|
||||
left: targetOffset,
|
||||
});
|
||||
},
|
||||
[columnPinning, containerRef, table],
|
||||
);
|
||||
|
||||
const searchMatches = useMemo(() => {
|
||||
const query = normalizeFilterValue(globalFilter);
|
||||
if (!query) return [];
|
||||
|
||||
const matches = [];
|
||||
for (const [rowIndex, row] of table.getRowModel().rows.entries()) {
|
||||
for (const cell of row.getVisibleCells()) {
|
||||
if (doesCellMatchFilter(cell.getValue(), cell.column.columnDef, query, vspRegistrationById)) {
|
||||
matches.push({ rowId: row.id, rowIndex, columnId: cell.column.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}, [data, globalFilter, table, vspRegistrationById, columnVisibility]);
|
||||
|
||||
const navigateToSearchMatch = useCallback(
|
||||
(direction) => {
|
||||
if (!searchMatches.length) return;
|
||||
|
||||
setSearchMatchIndex((currentIndex) => {
|
||||
const startIndex = currentIndex < 0 ? (direction > 0 ? -1 : 0) : currentIndex;
|
||||
const nextIndex = (startIndex + direction + searchMatches.length) % searchMatches.length;
|
||||
const match = searchMatches[nextIndex];
|
||||
|
||||
setActiveSearchMatch(match);
|
||||
setRowSelection({ [match.rowId]: true });
|
||||
console.log(match.columnId);
|
||||
handleNavigateToColumn(match.columnId);
|
||||
requestAnimationFrame(() => {
|
||||
rowVirtualizerRef.current?.scrollToIndex?.(match.rowIndex < 5 ? match.rowIndex : match.rowIndex + 5, { align: 'auto', behavior: 'smooth' });
|
||||
});
|
||||
|
||||
return nextIndex;
|
||||
});
|
||||
},
|
||||
[handleNavigateToColumn, searchMatches],
|
||||
);
|
||||
|
||||
const handleSearchKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (event.key !== 'Enter') return;
|
||||
|
||||
event.preventDefault();
|
||||
const direction = event.shiftKey ? -1 : 1;
|
||||
const inputValue = event.target.value;
|
||||
|
||||
if (normalizeFilterValue(inputValue) !== normalizeFilterValue(globalFilter)) {
|
||||
handleGlobalFilterChange.clear();
|
||||
setGlobalFilter(inputValue);
|
||||
setPendingSearchDirection(direction);
|
||||
return;
|
||||
}
|
||||
|
||||
navigateToSearchMatch(direction);
|
||||
},
|
||||
[globalFilter, handleGlobalFilterChange, navigateToSearchMatch],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchMatchIndex(-1);
|
||||
setActiveSearchMatch(null);
|
||||
}, [globalFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingSearchDirection) return;
|
||||
|
||||
navigateToSearchMatch(pendingSearchDirection);
|
||||
setPendingSearchDirection(0);
|
||||
}, [navigateToSearchMatch, pendingSearchDirection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dataEditingCells?.line_id) {
|
||||
setEditingCell(null);
|
||||
@ -623,6 +754,12 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||
columnsCurStage={columnsCurStage}
|
||||
onChangeSizeMult={setSizeMult}
|
||||
onGlobalFilterChange={handleGlobalFilterChange}
|
||||
globalFilter={globalFilter}
|
||||
onSearchKeyDown={handleSearchKeyDown}
|
||||
onSearchNext={() => navigateToSearchMatch(1)}
|
||||
onSearchPrevious={() => navigateToSearchMatch(-1)}
|
||||
searchMatchCount={searchMatches.length}
|
||||
searchMatchIndex={searchMatchIndex}
|
||||
sizeMult={sizeMult}
|
||||
onChangeShowColumnFilters={setShowColumnFilters}
|
||||
showColumnFilters={showColumnFilters}
|
||||
|
||||
@ -32,6 +32,32 @@ const controlSx = {
|
||||
};
|
||||
|
||||
const searchSx = { height: '2.5rem' };
|
||||
const searchNavigationSx = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
height: '2rem',
|
||||
border: '1px solid rgba(0, 0, 0, 0.12)',
|
||||
borderRadius: '0.4rem',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#fff',
|
||||
};
|
||||
const searchNavigationButtonSx = {
|
||||
width: '1.65rem',
|
||||
height: '2rem',
|
||||
minWidth: '1.65rem',
|
||||
padding: 0,
|
||||
borderRadius: 0,
|
||||
fontSize: '0.95rem',
|
||||
};
|
||||
const searchMatchCounterSx = {
|
||||
minWidth: '2.7rem',
|
||||
padding: '0 0.3rem',
|
||||
fontSize: '0.7rem',
|
||||
lineHeight: 1,
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
color: 'rgba(0, 0, 0, 0.65)',
|
||||
};
|
||||
const exportButtonSx = { height: '2.5rem', minHeight: '2.5rem' };
|
||||
|
||||
const SettingPanel = ({
|
||||
@ -46,6 +72,12 @@ const SettingPanel = ({
|
||||
columnsCurStage,
|
||||
onChangeSizeMult,
|
||||
onGlobalFilterChange,
|
||||
globalFilter,
|
||||
onSearchKeyDown,
|
||||
onSearchNext,
|
||||
onSearchPrevious,
|
||||
searchMatchCount,
|
||||
searchMatchIndex,
|
||||
sizeMult,
|
||||
onChangeShowColumnFilters,
|
||||
showColumnFilters,
|
||||
@ -218,7 +250,40 @@ const SettingPanel = ({
|
||||
</GroupByObject>
|
||||
<Divider orientation='vertical' flexItem />
|
||||
<GroupByObject title='Поиск'>
|
||||
<SearchComponent onChange={onGlobalFilterChange} height='2.5rem' sx={searchSx} />
|
||||
<SearchComponent
|
||||
value={globalFilter}
|
||||
onChange={onGlobalFilterChange}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
height='2.5rem'
|
||||
sx={searchSx}
|
||||
/>
|
||||
<span style={searchNavigationSx}>
|
||||
<Tooltip title='Предыдущий результат (Shift+Enter)'>
|
||||
<span>
|
||||
<IconButton
|
||||
onClick={onSearchPrevious}
|
||||
disabled={!searchMatchCount}
|
||||
sx={searchNavigationButtonSx}
|
||||
aria-label='Предыдущий результат поиска'>
|
||||
↑
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span style={searchMatchCounterSx}>
|
||||
{searchMatchCount ? `${Math.max(searchMatchIndex + 1, 1)}/${searchMatchCount}` : '0/0'}
|
||||
</span>
|
||||
<Tooltip title='Следующий результат (Enter)'>
|
||||
<span>
|
||||
<IconButton
|
||||
onClick={onSearchNext}
|
||||
disabled={!searchMatchCount}
|
||||
sx={searchNavigationButtonSx}
|
||||
aria-label='Следующий результат поиска'>
|
||||
↓
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<Tooltip title='Поиск по колонкам'>
|
||||
<IconButton
|
||||
variant='outlined'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user