settingPanel fix: исправление ошибок с поискам + переделывания поиска с фильтра на скролл
This commit is contained in:
parent
d9d67a2410
commit
ab2ad7fe06
@ -110,7 +110,7 @@ const createHighlightedContent = (originalValue, displayValue, searchQueries) =>
|
|||||||
const cleanQueries = searchQueries.filter(Boolean);
|
const cleanQueries = searchQueries.filter(Boolean);
|
||||||
if (cleanQueries.length === 0) return displayValue;
|
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 escapedQueries = cleanQueries.map((q) => q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||||
const regex = new RegExp(`(${escapedQueries.join('|')})`, 'gi');
|
const regex = new RegExp(`(${escapedQueries.join('|')})`, 'gi');
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import { SelectExpenseItemModal } from './Modals/SelectExpenseItemModal';
|
|||||||
import { SelectVspModal } from './Modals/SelectVspModal';
|
import { SelectVspModal } from './Modals/SelectVspModal';
|
||||||
import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig';
|
import { additionExpenseRowTable, additionVspRowTable } from './constants/addingRowConfig';
|
||||||
import { BASE_TABLE_CONFIG, TABLE_ROW_HEIGHT, getTableBodyCellProps, getTablePaperStyles } from './constants/tableConfig';
|
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 { useColumnSettings } from './hooks/useColumnSettings';
|
||||||
import { useHeaderPortal } from './hooks/useHeaderPortal';
|
import { useHeaderPortal } from './hooks/useHeaderPortal';
|
||||||
import { useTableScale } from './hooks/useTableScale';
|
import { useTableScale } from './hooks/useTableScale';
|
||||||
@ -39,15 +39,15 @@ const ROW_VIRTUALIZER_OPTIONS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getColumnVirtualizerOptions = ({ table }) => {
|
const getColumnVirtualizerOptions = ({ table }) => {
|
||||||
const orderedVisibleColumns = [
|
const orderedVisibleColumns = table.getVisibleLeafColumns();
|
||||||
...table.getLeftVisibleLeafColumns(),
|
|
||||||
...table.getCenterVisibleLeafColumns(),
|
|
||||||
...table.getRightVisibleLeafColumns(),
|
|
||||||
];
|
|
||||||
const getColumnSize = (index) => orderedVisibleColumns[index]?.getSize() ?? 150;
|
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 {
|
return {
|
||||||
overscan: 10,
|
overscan: 10,
|
||||||
|
scrollPaddingStart,
|
||||||
|
scrollPaddingEnd,
|
||||||
estimateSize: getColumnSize,
|
estimateSize: getColumnSize,
|
||||||
measureElement: (element) => {
|
measureElement: (element) => {
|
||||||
if (!element) return 150;
|
if (!element) return 150;
|
||||||
@ -59,6 +59,18 @@ const getColumnVirtualizerOptions = ({ table }) => {
|
|||||||
const hasVspDropdown = (columns) =>
|
const hasVspDropdown = (columns) =>
|
||||||
columns.some((column) => column.editType === 'vsp_dropdown' || (column.columns?.length && hasVspDropdown(column.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 = {
|
const tableContentStyle = {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@ -79,6 +91,7 @@ const loadingOverlayStyle = {
|
|||||||
|
|
||||||
const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const vspOptions = useVspOptions();
|
||||||
const userRoleId = user?.role_id;
|
const userRoleId = user?.role_id;
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@ -87,6 +100,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
editingCells: dataEditingCells,
|
editingCells: dataEditingCells,
|
||||||
} = useRealtimeData(formId, sheetName, direction, formType, year);
|
} = useRealtimeData(formId, sheetName, direction, formType, year);
|
||||||
const [globalFilter, setGlobalFilter] = useState('');
|
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 [showColumnFilters, setShowColumnFilters] = useState(false);
|
||||||
const [selectedColumnId, setSelectedColumnId] = useState();
|
const [selectedColumnId, setSelectedColumnId] = useState();
|
||||||
const [rowSelection, setRowSelection] = useState({});
|
const [rowSelection, setRowSelection] = useState({});
|
||||||
@ -120,9 +136,9 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
|
|
||||||
const handleGlobalFilterChange = useMemo(
|
const handleGlobalFilterChange = useMemo(
|
||||||
() =>
|
() =>
|
||||||
debounce((value) => {
|
debounce((valueOrUpdater) => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setGlobalFilter(value);
|
setGlobalFilter((currentValue) => (typeof valueOrUpdater === 'function' ? valueOrUpdater(currentValue) : valueOrUpdater));
|
||||||
});
|
});
|
||||||
}, 300),
|
}, 300),
|
||||||
[startTransition],
|
[startTransition],
|
||||||
@ -300,6 +316,23 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
[handleUpdateCell],
|
[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 = ({
|
const createTableConfig = ({
|
||||||
columns,
|
columns,
|
||||||
data,
|
data,
|
||||||
@ -317,8 +350,11 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
setExpanded,
|
setExpanded,
|
||||||
editingCell,
|
editingCell,
|
||||||
contextStartEditing,
|
contextStartEditing,
|
||||||
|
globalFilterFn,
|
||||||
|
activeSearchMatch,
|
||||||
}) => ({
|
}) => ({
|
||||||
...BASE_TABLE_CONFIG,
|
...BASE_TABLE_CONFIG,
|
||||||
|
globalFilterFn,
|
||||||
columns,
|
columns,
|
||||||
data,
|
data,
|
||||||
enableRowVirtualization: true,
|
enableRowVirtualization: true,
|
||||||
@ -355,7 +391,21 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
muiTableHeadCellProps: {
|
muiTableHeadCellProps: {
|
||||||
sx: { boxSizing: 'border-box' },
|
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: {
|
muiTableHeadProps: {
|
||||||
sx: {
|
sx: {
|
||||||
display: 'table-header-group',
|
display: 'table-header-group',
|
||||||
@ -406,6 +456,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
setExpanded,
|
setExpanded,
|
||||||
editingCell,
|
editingCell,
|
||||||
contextStartEditing,
|
contextStartEditing,
|
||||||
|
globalFilterFn,
|
||||||
|
activeSearchMatch,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
columns,
|
columns,
|
||||||
@ -422,6 +474,8 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
setColumnPinning,
|
setColumnPinning,
|
||||||
editingCell,
|
editingCell,
|
||||||
expanded,
|
expanded,
|
||||||
|
globalFilterFn,
|
||||||
|
activeSearchMatch,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -431,31 +485,108 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
const handleNavigateToColumn = useCallback(
|
const handleNavigateToColumn = useCallback(
|
||||||
(columnId) => {
|
(columnId) => {
|
||||||
if (!columnId) return;
|
if (!columnId) return;
|
||||||
|
console.log(columnId);
|
||||||
|
console.log(columnId);
|
||||||
|
console.log(columnId);
|
||||||
|
console.log(columnId);
|
||||||
|
|
||||||
setSelectedColumnId(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;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const pinnedIds = new Set(columnPinning?.left || []);
|
const leftPinnedSize = table.getLeftVisibleLeafColumns().reduce((width, column) => width + column.getSize(), 0);
|
||||||
if (pinnedIds.has(columnId)) return;
|
const calculatedStart = visibleColumns.slice(0, columnIndex).reduce((offset, column) => offset + column.getSize(), 0);
|
||||||
|
const measurement = columnVirtualizer?.measurementsCache?.[columnIndex];
|
||||||
const centerColumns = table.getVisibleLeafColumns().filter((column) => !pinnedIds.has(column.id));
|
const targetStart = measurement?.start ?? calculatedStart;
|
||||||
|
const targetOffset = Math.max(0, targetStart - leftPinnedSize);
|
||||||
let offset = 0;
|
|
||||||
for (const column of centerColumns) {
|
|
||||||
if (column.id === columnId) break;
|
|
||||||
offset += column.getSize();
|
|
||||||
}
|
|
||||||
|
|
||||||
container.scrollTo({
|
container.scrollTo({
|
||||||
left: Math.max(0, offset - 40),
|
left: targetOffset,
|
||||||
behavior: 'smooth',
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[columnPinning, containerRef, table],
|
[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(() => {
|
useEffect(() => {
|
||||||
if (!dataEditingCells?.line_id) {
|
if (!dataEditingCells?.line_id) {
|
||||||
setEditingCell(null);
|
setEditingCell(null);
|
||||||
@ -623,6 +754,12 @@ const RealtimeTable = ({ formType, formId, sheetName, direction, year }) => {
|
|||||||
columnsCurStage={columnsCurStage}
|
columnsCurStage={columnsCurStage}
|
||||||
onChangeSizeMult={setSizeMult}
|
onChangeSizeMult={setSizeMult}
|
||||||
onGlobalFilterChange={handleGlobalFilterChange}
|
onGlobalFilterChange={handleGlobalFilterChange}
|
||||||
|
globalFilter={globalFilter}
|
||||||
|
onSearchKeyDown={handleSearchKeyDown}
|
||||||
|
onSearchNext={() => navigateToSearchMatch(1)}
|
||||||
|
onSearchPrevious={() => navigateToSearchMatch(-1)}
|
||||||
|
searchMatchCount={searchMatches.length}
|
||||||
|
searchMatchIndex={searchMatchIndex}
|
||||||
sizeMult={sizeMult}
|
sizeMult={sizeMult}
|
||||||
onChangeShowColumnFilters={setShowColumnFilters}
|
onChangeShowColumnFilters={setShowColumnFilters}
|
||||||
showColumnFilters={showColumnFilters}
|
showColumnFilters={showColumnFilters}
|
||||||
|
|||||||
@ -32,6 +32,32 @@ const controlSx = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const searchSx = { height: '2.5rem' };
|
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 exportButtonSx = { height: '2.5rem', minHeight: '2.5rem' };
|
||||||
|
|
||||||
const SettingPanel = ({
|
const SettingPanel = ({
|
||||||
@ -46,6 +72,12 @@ const SettingPanel = ({
|
|||||||
columnsCurStage,
|
columnsCurStage,
|
||||||
onChangeSizeMult,
|
onChangeSizeMult,
|
||||||
onGlobalFilterChange,
|
onGlobalFilterChange,
|
||||||
|
globalFilter,
|
||||||
|
onSearchKeyDown,
|
||||||
|
onSearchNext,
|
||||||
|
onSearchPrevious,
|
||||||
|
searchMatchCount,
|
||||||
|
searchMatchIndex,
|
||||||
sizeMult,
|
sizeMult,
|
||||||
onChangeShowColumnFilters,
|
onChangeShowColumnFilters,
|
||||||
showColumnFilters,
|
showColumnFilters,
|
||||||
@ -218,7 +250,40 @@ const SettingPanel = ({
|
|||||||
</GroupByObject>
|
</GroupByObject>
|
||||||
<Divider orientation='vertical' flexItem />
|
<Divider orientation='vertical' flexItem />
|
||||||
<GroupByObject title='Поиск'>
|
<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='Поиск по колонкам'>
|
<Tooltip title='Поиск по колонкам'>
|
||||||
<IconButton
|
<IconButton
|
||||||
variant='outlined'
|
variant='outlined'
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user