mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(kb-tags): docs page kb tags ui (#838)
* fix(kb-tags): docs page kb tags ui * remove console logs * remove console error
This commit is contained in:
committed by
GitHub
parent
5b53cc2be6
commit
608964a8b3
@@ -6,10 +6,6 @@ import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
type DocumentTag,
|
||||
DocumentTagEntry,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/components/document-tag-entry/document-tag-entry'
|
||||
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
|
||||
|
||||
const logger = createLogger('UploadModal')
|
||||
@@ -50,7 +46,7 @@ export function UploadModal({
|
||||
}: UploadModalProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [files, setFiles] = useState<FileWithPreview[]>([])
|
||||
const [tags, setTags] = useState<DocumentTag[]>([])
|
||||
|
||||
const [fileError, setFileError] = useState<string | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
@@ -66,7 +62,6 @@ export function UploadModal({
|
||||
if (isUploading) return // Prevent closing during upload
|
||||
|
||||
setFiles([])
|
||||
setTags([])
|
||||
setFileError(null)
|
||||
setIsDragging(false)
|
||||
onOpenChange(false)
|
||||
@@ -145,23 +140,7 @@ export function UploadModal({
|
||||
if (files.length === 0) return
|
||||
|
||||
try {
|
||||
// Convert DocumentTag array to TagData format
|
||||
const tagData: Record<string, string> = {}
|
||||
tags.forEach((tag) => {
|
||||
if (tag.value.trim()) {
|
||||
tagData[tag.slot] = tag.value.trim()
|
||||
}
|
||||
})
|
||||
|
||||
// Create files with tags for upload
|
||||
const filesWithTags = files.map((file) => {
|
||||
// Add tags as custom properties to the file object
|
||||
const fileWithTags = file as unknown as File & Record<string, string>
|
||||
Object.assign(fileWithTags, tagData)
|
||||
return fileWithTags
|
||||
})
|
||||
|
||||
await uploadFiles(filesWithTags, knowledgeBaseId, {
|
||||
await uploadFiles(files, knowledgeBaseId, {
|
||||
chunkSize: chunkingConfig?.maxSize || 1024,
|
||||
minCharactersPerChunk: chunkingConfig?.minSize || 100,
|
||||
chunkOverlap: chunkingConfig?.overlap || 200,
|
||||
@@ -180,19 +159,6 @@ export function UploadModal({
|
||||
</DialogHeader>
|
||||
|
||||
<div className='flex-1 space-y-6 overflow-auto'>
|
||||
{/* Document Tag Entry Section */}
|
||||
<DocumentTagEntry
|
||||
tags={tags}
|
||||
onTagsChange={setTags}
|
||||
disabled={isUploading}
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
documentId={null} // No specific document for upload
|
||||
onSave={async () => {
|
||||
// For upload modal, tags are saved when document is uploaded
|
||||
// This is a placeholder as tags will be applied during upload
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* File Upload Section */}
|
||||
<div className='space-y-3'>
|
||||
<Label>Select Files</Label>
|
||||
|
||||
@@ -3,14 +3,23 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, Plus, X } from 'lucide-react'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
formatDisplayText,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui'
|
||||
import { MAX_TAG_SLOTS, TAG_SLOTS, type TagSlot } from '@/lib/constants/knowledge'
|
||||
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'
|
||||
@@ -29,7 +38,7 @@ interface DocumentTagEntryProps {
|
||||
disabled?: boolean
|
||||
knowledgeBaseId: string
|
||||
documentId: string | null
|
||||
onSave: (tagsToSave: DocumentTag[]) => Promise<void>
|
||||
onSave?: (tagsToSave: DocumentTag[]) => Promise<void>
|
||||
}
|
||||
|
||||
export function DocumentTagEntry({
|
||||
@@ -40,19 +49,26 @@ export function DocumentTagEntry({
|
||||
documentId,
|
||||
onSave,
|
||||
}: DocumentTagEntryProps) {
|
||||
const { saveTagDefinitions } = useTagDefinitions(knowledgeBaseId, documentId)
|
||||
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } =
|
||||
useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
// Use different hooks based on whether we have a documentId
|
||||
const documentTagHook = useTagDefinitions(knowledgeBaseId, documentId)
|
||||
const kbTagHook = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
|
||||
const [editingTag, setEditingTag] = useState<{
|
||||
index: number
|
||||
value: string
|
||||
tagName: string
|
||||
isNew: boolean
|
||||
} | null>(null)
|
||||
// Use the document-level hook since we have documentId
|
||||
const { saveTagDefinitions } = documentTagHook
|
||||
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } = kbTagHook
|
||||
|
||||
// Modal state for tag editing
|
||||
const [editingTagIndex, setEditingTagIndex] = useState<number | null>(null)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editForm, setEditForm] = useState({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
value: '',
|
||||
})
|
||||
|
||||
const getNextAvailableSlot = (): DocumentTag['slot'] => {
|
||||
const usedSlots = new Set(tags.map((tag) => tag.slot))
|
||||
// Check which slots are used at the KB level (tag definitions)
|
||||
const usedSlots = new Set(kbTagDefinitions.map((def) => def.tagSlot))
|
||||
for (const slot of TAG_SLOTS) {
|
||||
if (!usedSlots.has(slot)) {
|
||||
return slot
|
||||
@@ -61,83 +77,114 @@ export function DocumentTagEntry({
|
||||
return TAG_SLOTS[0] // Fallback to first slot if all are used
|
||||
}
|
||||
|
||||
const handleAddTag = () => {
|
||||
if (tags.length >= MAX_TAG_SLOTS) return
|
||||
|
||||
const newTag: DocumentTag = {
|
||||
slot: getNextAvailableSlot(),
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
value: '',
|
||||
}
|
||||
|
||||
const updatedTags = [...tags, newTag]
|
||||
onTagsChange(updatedTags)
|
||||
|
||||
// Set editing state for the new tag
|
||||
setEditingTag({
|
||||
index: updatedTags.length - 1,
|
||||
value: '',
|
||||
tagName: '',
|
||||
isNew: true,
|
||||
})
|
||||
}
|
||||
|
||||
const handleRemoveTag = (index: number) => {
|
||||
const updatedTags = tags.filter((_, i) => i !== index)
|
||||
onTagsChange(updatedTags)
|
||||
}
|
||||
|
||||
const handleTagUpdate = (index: number, field: keyof DocumentTag, value: string) => {
|
||||
const updatedTags = [...tags]
|
||||
updatedTags[index] = { ...updatedTags[index], [field]: value }
|
||||
onTagsChange(updatedTags)
|
||||
// Open modal to edit tag
|
||||
const openTagModal = (index: number) => {
|
||||
const tag = tags[index]
|
||||
setEditingTagIndex(index)
|
||||
setEditForm({
|
||||
displayName: tag.displayName,
|
||||
fieldType: tag.fieldType,
|
||||
value: tag.value,
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveTag = async (index: number, tagName: string) => {
|
||||
if (!tagName.trim()) return
|
||||
|
||||
// Check if this is creating a new tag definition
|
||||
const existingDefinition = kbTagDefinitions.find(
|
||||
(def) => def.displayName.toLowerCase() === tagName.toLowerCase()
|
||||
)
|
||||
|
||||
if (!existingDefinition) {
|
||||
// Create new tag definition
|
||||
const newDefinition: TagDefinitionInput = {
|
||||
displayName: tagName,
|
||||
fieldType: 'text',
|
||||
tagSlot: tags[index].slot as TagSlot,
|
||||
}
|
||||
|
||||
try {
|
||||
await saveTagDefinitions([newDefinition])
|
||||
await refreshTagDefinitions()
|
||||
} catch (error) {
|
||||
console.error('Failed to save tag definition:', error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Update the tag
|
||||
handleTagUpdate(index, 'displayName', tagName)
|
||||
setEditingTag(null)
|
||||
// Open modal to create new tag
|
||||
const openNewTagModal = () => {
|
||||
setEditingTagIndex(null)
|
||||
setEditForm({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
value: '',
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
if (editingTag?.isNew) {
|
||||
// Remove the new tag if editing was cancelled
|
||||
handleRemoveTag(editingTag.index)
|
||||
}
|
||||
setEditingTag(null)
|
||||
}
|
||||
// Save tag from modal
|
||||
const saveTagFromModal = async () => {
|
||||
if (!editForm.displayName.trim()) return
|
||||
|
||||
const handleSaveAll = async () => {
|
||||
try {
|
||||
await onSave(tags)
|
||||
} catch (error) {
|
||||
console.error('Failed to save tags:', error)
|
||||
}
|
||||
if (editingTagIndex !== null) {
|
||||
// Editing existing tag
|
||||
const updatedTags = [...tags]
|
||||
updatedTags[editingTagIndex] = {
|
||||
...updatedTags[editingTagIndex],
|
||||
displayName: editForm.displayName,
|
||||
fieldType: editForm.fieldType,
|
||||
value: editForm.value,
|
||||
}
|
||||
onTagsChange(updatedTags)
|
||||
} else {
|
||||
// Creating new tag - calculate slot once
|
||||
const newSlot = getNextAvailableSlot()
|
||||
const newTag: DocumentTag = {
|
||||
slot: newSlot,
|
||||
displayName: editForm.displayName,
|
||||
fieldType: editForm.fieldType,
|
||||
value: editForm.value,
|
||||
}
|
||||
const newTags = [...tags, newTag]
|
||||
onTagsChange(newTags)
|
||||
}
|
||||
|
||||
// Auto-save tag definition if it's a new name
|
||||
const existingDefinition = kbTagDefinitions.find(
|
||||
(def) => def.displayName.toLowerCase() === editForm.displayName.toLowerCase()
|
||||
)
|
||||
|
||||
if (!existingDefinition) {
|
||||
// Use the same slot for both tag and definition
|
||||
const targetSlot =
|
||||
editingTagIndex !== null ? tags[editingTagIndex].slot : getNextAvailableSlot()
|
||||
|
||||
const newDefinition: TagDefinitionInput = {
|
||||
displayName: editForm.displayName,
|
||||
fieldType: editForm.fieldType,
|
||||
tagSlot: targetSlot as TagSlot,
|
||||
}
|
||||
|
||||
if (saveTagDefinitions) {
|
||||
await saveTagDefinitions([newDefinition])
|
||||
} else {
|
||||
throw new Error('Cannot save tag definitions without a document ID')
|
||||
}
|
||||
await refreshTagDefinitions()
|
||||
}
|
||||
|
||||
// Save the actual document tags if onSave is provided
|
||||
if (onSave) {
|
||||
const updatedTags =
|
||||
editingTagIndex !== null
|
||||
? tags.map((tag, index) =>
|
||||
index === editingTagIndex
|
||||
? {
|
||||
...tag,
|
||||
displayName: editForm.displayName,
|
||||
fieldType: editForm.fieldType,
|
||||
value: editForm.value,
|
||||
}
|
||||
: tag
|
||||
)
|
||||
: [
|
||||
...tags,
|
||||
{
|
||||
slot: getNextAvailableSlot(),
|
||||
displayName: editForm.displayName,
|
||||
fieldType: editForm.fieldType,
|
||||
value: editForm.value,
|
||||
},
|
||||
]
|
||||
await onSave(updatedTags)
|
||||
}
|
||||
|
||||
setModalOpen(false)
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
// Filter available tag definitions (exclude already used ones)
|
||||
@@ -149,142 +196,149 @@ export function DocumentTagEntry({
|
||||
<div className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h3 className='font-medium text-sm'>Document Tags</h3>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleAddTag}
|
||||
disabled={disabled || tags.length >= MAX_TAG_SLOTS}
|
||||
>
|
||||
<Plus className='mr-1 h-3 w-3' />
|
||||
Add Tag
|
||||
</Button>
|
||||
<Button variant='default' size='sm' onClick={handleSaveAll} disabled={disabled}>
|
||||
Save Tags
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tags.length === 0 ? (
|
||||
{/* Tags as Badges */}
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{tags.map((tag, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant='outline'
|
||||
className='cursor-pointer gap-2 px-3 py-1.5 text-sm transition-colors hover:bg-accent'
|
||||
onClick={() => openTagModal(index)}
|
||||
>
|
||||
<span className='font-medium'>{tag.displayName || 'Unnamed Tag'}</span>
|
||||
{tag.value && (
|
||||
<>
|
||||
<span className='text-muted-foreground'>:</span>
|
||||
<span className='text-muted-foreground'>{tag.value}</span>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemoveTag(index)
|
||||
}}
|
||||
disabled={disabled}
|
||||
className='ml-1 h-4 w-4 p-0 text-muted-foreground hover:text-red-600'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{/* Add Tag Button */}
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={openNewTagModal}
|
||||
disabled={disabled || tags.length >= MAX_TAG_SLOTS}
|
||||
className='gap-1 border-dashed text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
Add Tag
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tags.length === 0 && (
|
||||
<div className='rounded-md border border-dashed p-4 text-center'>
|
||||
<p className='text-muted-foreground text-sm'>No tags added yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-2'>
|
||||
{tags.map((tag, index) => (
|
||||
<div key={index} className='flex items-center gap-2 rounded-md border p-3'>
|
||||
<div className='flex-1'>
|
||||
{editingTag?.index === index ? (
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
value={editingTag.tagName}
|
||||
onChange={(e) => setEditingTag({ ...editingTag, tagName: e.target.value })}
|
||||
placeholder='Tag name'
|
||||
className='flex-1'
|
||||
autoFocus
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='outline' size='sm'>
|
||||
Select Existing <ChevronDown className='ml-1 h-3 w-3' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{availableDefinitions.map((def) => (
|
||||
<DropdownMenuItem
|
||||
key={def.id}
|
||||
onClick={() =>
|
||||
setEditingTag({ ...editingTag, tagName: def.displayName })
|
||||
}
|
||||
>
|
||||
{def.displayName}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{availableDefinitions.length === 0 && (
|
||||
<DropdownMenuItem disabled>No available tags</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() => handleSaveTag(index, editingTag.tagName)}
|
||||
disabled={!editingTag.tagName.trim()}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button variant='outline' size='sm' onClick={handleCancelEdit}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<div className='font-medium text-sm'>
|
||||
{tag.displayName || 'Unnamed Tag'}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
Slot: {tag.slot} • Type: {tag.fieldType}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
setEditingTag({
|
||||
index,
|
||||
value: tag.value,
|
||||
tagName: tag.displayName,
|
||||
isNew: false,
|
||||
})
|
||||
}
|
||||
disabled={disabled}
|
||||
>
|
||||
Edit Name
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label htmlFor={`tag-value-${index}`} className='text-xs'>
|
||||
Value:
|
||||
</Label>
|
||||
<div className='relative flex-1'>
|
||||
<Input
|
||||
id={`tag-value-${index}`}
|
||||
value={tag.value}
|
||||
onChange={(e) => handleTagUpdate(index, 'value', e.target.value)}
|
||||
placeholder='Enter tag value'
|
||||
disabled={disabled}
|
||||
className='w-full text-transparent caret-foreground'
|
||||
/>
|
||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
|
||||
<div className='whitespace-pre'>{formatDisplayText(tag.value)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => handleRemoveTag(index)}
|
||||
disabled={disabled}
|
||||
className='text-red-600 hover:text-red-800'
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
No tags added yet. Click "Add Tag" to get started.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{tags.length} of {MAX_TAG_SLOTS} tag slots used
|
||||
{kbTagDefinitions.length} of {MAX_TAG_SLOTS} tag slots used
|
||||
</div>
|
||||
|
||||
{/* Tag Edit Modal */}
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent className='sm:max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingTagIndex !== null ? 'Edit Tag' : 'Add New Tag'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='space-y-4'>
|
||||
{/* Tag Name */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='tag-name'>Tag Name</Label>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
id='tag-name'
|
||||
value={editForm.displayName}
|
||||
onChange={(e) => setEditForm({ ...editForm, displayName: e.target.value })}
|
||||
placeholder='Enter tag name'
|
||||
className='flex-1'
|
||||
/>
|
||||
{availableDefinitions.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='outline' size='sm'>
|
||||
<ChevronDown className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
{availableDefinitions.map((def) => (
|
||||
<DropdownMenuItem
|
||||
key={def.id}
|
||||
onClick={() =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
displayName: def.displayName,
|
||||
fieldType: def.fieldType,
|
||||
})
|
||||
}
|
||||
>
|
||||
{def.displayName}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag Type */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='tag-type'>Type</Label>
|
||||
<Select
|
||||
value={editForm.fieldType}
|
||||
onValueChange={(value) => setEditForm({ ...editForm, fieldType: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='text'>Text</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Tag Value */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='tag-value'>Value</Label>
|
||||
<Input
|
||||
id='tag-value'
|
||||
value={editForm.value}
|
||||
onChange={(e) => setEditForm({ ...editForm, value: e.target.value })}
|
||||
placeholder='Enter tag value'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-2 pt-4'>
|
||||
<Button variant='outline' onClick={() => setModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={saveTagFromModal} disabled={!editForm.displayName.trim()}>
|
||||
{editingTagIndex !== null ? 'Save Changes' : 'Add Tag'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user