mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 22:48:14 -05:00
feat(kb): added permissions to workspace popover, added kb popover to view tags, edit description and kb name (#2634)
This commit is contained in:
@@ -1,8 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Badge, DocumentAttachment, Tooltip } from '@/components/emcn'
|
||||
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { DeleteKnowledgeBaseModal } from '../delete-knowledge-base-modal/delete-knowledge-base-modal'
|
||||
import { EditKnowledgeBaseModal } from '../edit-knowledge-base-modal/edit-knowledge-base-modal'
|
||||
import { KnowledgeBaseContextMenu } from '../knowledge-base-context-menu/knowledge-base-context-menu'
|
||||
|
||||
interface BaseCardProps {
|
||||
id?: string
|
||||
@@ -11,6 +17,8 @@ interface BaseCardProps {
|
||||
description: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
onUpdate?: (id: string, name: string, description: string) => Promise<void>
|
||||
onDelete?: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,9 +117,32 @@ export function BaseCardSkeletonGrid({ count = 8 }: { count?: number }) {
|
||||
/**
|
||||
* Knowledge base card component displaying overview information
|
||||
*/
|
||||
export function BaseCard({ id, title, docCount, description, updatedAt }: BaseCardProps) {
|
||||
export function BaseCard({
|
||||
id,
|
||||
title,
|
||||
docCount,
|
||||
description,
|
||||
updatedAt,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: BaseCardProps) {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const workspaceId = params?.workspaceId as string
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const {
|
||||
isOpen: isContextMenuOpen,
|
||||
position: contextMenuPosition,
|
||||
menuRef,
|
||||
handleContextMenu,
|
||||
closeMenu: closeContextMenu,
|
||||
} = useContextMenu()
|
||||
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
kbName: title,
|
||||
@@ -120,41 +151,154 @@ export function BaseCard({ id, title, docCount, description, updatedAt }: BaseCa
|
||||
|
||||
const shortId = id ? `kb-${id.slice(0, 8)}` : ''
|
||||
|
||||
return (
|
||||
<Link href={href} prefetch={true} className='h-full'>
|
||||
<div className='group flex h-full cursor-pointer flex-col gap-[12px] rounded-[4px] bg-[var(--surface-3)] px-[8px] py-[6px] transition-colors hover:bg-[var(--surface-4)] dark:bg-[var(--surface-4)] dark:hover:bg-[var(--surface-5)]'>
|
||||
<div className='flex items-center justify-between gap-[8px]'>
|
||||
<h3 className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-primary)]'>
|
||||
{title}
|
||||
</h3>
|
||||
{shortId && <Badge className='flex-shrink-0 rounded-[4px] text-[12px]'>{shortId}</Badge>}
|
||||
</div>
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (isContextMenuOpen) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
router.push(href)
|
||||
},
|
||||
[isContextMenuOpen, router, href]
|
||||
)
|
||||
|
||||
<div className='flex flex-1 flex-col gap-[8px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='flex items-center gap-[6px] text-[12px] text-[var(--text-tertiary)]'>
|
||||
<DocumentAttachment className='h-[12px] w-[12px]' />
|
||||
{docCount} {docCount === 1 ? 'doc' : 'docs'}
|
||||
</span>
|
||||
{updatedAt && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
last updated: {formatRelativeTime(updatedAt)}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{formatAbsoluteDate(updatedAt)}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
router.push(href)
|
||||
}
|
||||
},
|
||||
[router, href]
|
||||
)
|
||||
|
||||
const handleOpenInNewTab = useCallback(() => {
|
||||
window.open(href, '_blank')
|
||||
}, [href])
|
||||
|
||||
const handleViewTags = useCallback(() => {
|
||||
setIsTagsModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
setIsEditModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
setIsDeleteModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!id || !onDelete) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await onDelete(id)
|
||||
setIsDeleteModalOpen(false)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}, [id, onDelete])
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (knowledgeBaseId: string, name: string, newDescription: string) => {
|
||||
if (!onUpdate) return
|
||||
await onUpdate(knowledgeBaseId, name, newDescription)
|
||||
},
|
||||
[onUpdate]
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
className='h-full cursor-pointer'
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<div className='group flex h-full flex-col gap-[12px] rounded-[4px] bg-[var(--surface-3)] px-[8px] py-[6px] transition-colors hover:bg-[var(--surface-4)] dark:bg-[var(--surface-4)] dark:hover:bg-[var(--surface-5)]'>
|
||||
<div className='flex items-center justify-between gap-[8px]'>
|
||||
<h3 className='min-w-0 flex-1 truncate font-medium text-[14px] text-[var(--text-primary)]'>
|
||||
{title}
|
||||
</h3>
|
||||
{shortId && (
|
||||
<Badge className='flex-shrink-0 rounded-[4px] text-[12px]'>{shortId}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='h-0 w-full border-[var(--divider)] border-t' />
|
||||
<div className='flex flex-1 flex-col gap-[8px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='flex items-center gap-[6px] text-[12px] text-[var(--text-tertiary)]'>
|
||||
<DocumentAttachment className='h-[12px] w-[12px]' />
|
||||
{docCount} {docCount === 1 ? 'doc' : 'docs'}
|
||||
</span>
|
||||
{updatedAt && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
last updated: {formatRelativeTime(updatedAt)}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>{formatAbsoluteDate(updatedAt)}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className='line-clamp-2 h-[36px] text-[12px] text-[var(--text-tertiary)] leading-[18px]'>
|
||||
{description}
|
||||
</p>
|
||||
<div className='h-0 w-full border-[var(--divider)] border-t' />
|
||||
|
||||
<p className='line-clamp-2 h-[36px] text-[12px] text-[var(--text-tertiary)] leading-[18px]'>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<KnowledgeBaseContextMenu
|
||||
isOpen={isContextMenuOpen}
|
||||
position={contextMenuPosition}
|
||||
menuRef={menuRef}
|
||||
onClose={closeContextMenu}
|
||||
onOpenInNewTab={handleOpenInNewTab}
|
||||
onViewTags={handleViewTags}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
showOpenInNewTab={true}
|
||||
showViewTags={!!id}
|
||||
showEdit={!!onUpdate}
|
||||
showDelete={!!onDelete}
|
||||
disableEdit={!userPermissions.canEdit}
|
||||
disableDelete={!userPermissions.canEdit}
|
||||
/>
|
||||
|
||||
{id && onUpdate && (
|
||||
<EditKnowledgeBaseModal
|
||||
open={isEditModalOpen}
|
||||
onOpenChange={setIsEditModalOpen}
|
||||
knowledgeBaseId={id}
|
||||
initialName={title}
|
||||
initialDescription={description === 'No description provided' ? '' : description}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{id && onDelete && (
|
||||
<DeleteKnowledgeBaseModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
isDeleting={isDeleting}
|
||||
knowledgeBaseName={title}
|
||||
/>
|
||||
)}
|
||||
|
||||
{id && (
|
||||
<BaseTagsModal
|
||||
open={isTagsModalOpen}
|
||||
onOpenChange={setIsTagsModalOpen}
|
||||
knowledgeBaseId={id}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
|
||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
||||
|
||||
interface DeleteKnowledgeBaseModalProps {
|
||||
/**
|
||||
* Whether the modal is open
|
||||
*/
|
||||
isOpen: boolean
|
||||
/**
|
||||
* Callback when modal should close
|
||||
*/
|
||||
onClose: () => void
|
||||
/**
|
||||
* Callback when delete is confirmed
|
||||
*/
|
||||
onConfirm: () => void
|
||||
/**
|
||||
* Whether the delete operation is in progress
|
||||
*/
|
||||
isDeleting: boolean
|
||||
/**
|
||||
* Name of the knowledge base being deleted
|
||||
*/
|
||||
knowledgeBaseName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete confirmation modal for knowledge base items.
|
||||
* Displays a warning message and confirmation buttons.
|
||||
*/
|
||||
export function DeleteKnowledgeBaseModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isDeleting,
|
||||
knowledgeBaseName,
|
||||
}: DeleteKnowledgeBaseModalProps) {
|
||||
return (
|
||||
<Modal open={isOpen} onOpenChange={onClose}>
|
||||
<ModalContent className='w-[400px]'>
|
||||
<ModalHeader>Delete Knowledge Base</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
{knowledgeBaseName ? (
|
||||
<>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{knowledgeBaseName}</span>?
|
||||
This will permanently remove all associated documents, chunks, and embeddings.
|
||||
</>
|
||||
) : (
|
||||
'Are you sure you want to delete this knowledge base? This will permanently remove all associated documents, chunks, and embeddings.'
|
||||
)}{' '}
|
||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||
</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant='active' onClick={onClose} disabled={isDeleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={onConfirm} disabled={isDeleting}>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
const logger = createLogger('EditKnowledgeBaseModal')
|
||||
|
||||
interface EditKnowledgeBaseModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
knowledgeBaseId: string
|
||||
initialName: string
|
||||
initialDescription: string
|
||||
onSave: (id: string, name: string, description: string) => Promise<void>
|
||||
}
|
||||
|
||||
const FormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, 'Name is required')
|
||||
.max(100, 'Name must be less than 100 characters')
|
||||
.refine((value) => value.trim().length > 0, 'Name cannot be empty'),
|
||||
description: z.string().max(500, 'Description must be less than 500 characters').optional(),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof FormSchema>
|
||||
|
||||
/**
|
||||
* Modal for editing knowledge base name and description
|
||||
*/
|
||||
export function EditKnowledgeBaseModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
knowledgeBaseId,
|
||||
initialName,
|
||||
initialDescription,
|
||||
onSave,
|
||||
}: EditKnowledgeBaseModalProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
name: initialName,
|
||||
description: initialDescription,
|
||||
},
|
||||
mode: 'onSubmit',
|
||||
})
|
||||
|
||||
const nameValue = watch('name')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setError(null)
|
||||
reset({
|
||||
name: initialName,
|
||||
description: initialDescription,
|
||||
})
|
||||
}
|
||||
}, [open, initialName, initialDescription, reset])
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onSave(knowledgeBaseId, data.name.trim(), data.description?.trim() || '')
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
logger.error('Error updating knowledge base:', err)
|
||||
setError(err instanceof Error ? err.message : 'Failed to update knowledge base')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent>
|
||||
<ModalHeader>Edit Knowledge Base</ModalHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
|
||||
<ModalBody className='!pb-[16px]'>
|
||||
<div className='space-y-[12px]'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='kb-name'>Name</Label>
|
||||
<Input
|
||||
id='kb-name'
|
||||
placeholder='Enter knowledge base name'
|
||||
{...register('name')}
|
||||
className={cn(errors.name && 'border-[var(--text-error)]')}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className='text-[11px] text-[var(--text-error)]'>{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='description'>Description</Label>
|
||||
<Textarea
|
||||
id='description'
|
||||
placeholder='Describe this knowledge base (optional)'
|
||||
rows={3}
|
||||
{...register('description')}
|
||||
className={cn(errors.description && 'border-[var(--text-error)]')}
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className='text-[11px] text-[var(--text-error)]'>
|
||||
{errors.description.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<div className='flex w-full items-center justify-between gap-[12px]'>
|
||||
{error ? (
|
||||
<p className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
{error}
|
||||
</p>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className='flex flex-shrink-0 gap-[8px]'>
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={() => onOpenChange(false)}
|
||||
type='button'
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
type='submit'
|
||||
disabled={isSubmitting || !nameValue?.trim()}
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
export { BaseCard, BaseCardSkeleton, BaseCardSkeletonGrid } from './base-card/base-card'
|
||||
export { CreateBaseModal } from './create-base-modal/create-base-modal'
|
||||
export { DeleteKnowledgeBaseModal } from './delete-knowledge-base-modal/delete-knowledge-base-modal'
|
||||
export { EditKnowledgeBaseModal } from './edit-knowledge-base-modal/edit-knowledge-base-modal'
|
||||
export { getDocumentIcon } from './icons/document-icons'
|
||||
export { KnowledgeBaseContextMenu } from './knowledge-base-context-menu/knowledge-base-context-menu'
|
||||
export { KnowledgeHeader } from './knowledge-header/knowledge-header'
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
|
||||
import { Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
|
||||
|
||||
interface KnowledgeBaseContextMenuProps {
|
||||
/**
|
||||
* Whether the context menu is open
|
||||
*/
|
||||
isOpen: boolean
|
||||
/**
|
||||
* Position of the context menu
|
||||
*/
|
||||
position: { x: number; y: number }
|
||||
/**
|
||||
* Ref for the menu element
|
||||
*/
|
||||
menuRef: React.RefObject<HTMLDivElement | null>
|
||||
/**
|
||||
* Callback when menu should close
|
||||
*/
|
||||
onClose: () => void
|
||||
/**
|
||||
* Callback when open in new tab is clicked
|
||||
*/
|
||||
onOpenInNewTab?: () => void
|
||||
/**
|
||||
* Callback when view tags is clicked
|
||||
*/
|
||||
onViewTags?: () => void
|
||||
/**
|
||||
* Callback when edit is clicked
|
||||
*/
|
||||
onEdit?: () => void
|
||||
/**
|
||||
* Callback when delete is clicked
|
||||
*/
|
||||
onDelete?: () => void
|
||||
/**
|
||||
* Whether to show the open in new tab option
|
||||
* @default true
|
||||
*/
|
||||
showOpenInNewTab?: boolean
|
||||
/**
|
||||
* Whether to show the view tags option
|
||||
* @default true
|
||||
*/
|
||||
showViewTags?: boolean
|
||||
/**
|
||||
* Whether to show the edit option
|
||||
* @default true
|
||||
*/
|
||||
showEdit?: boolean
|
||||
/**
|
||||
* Whether to show the delete option
|
||||
* @default true
|
||||
*/
|
||||
showDelete?: boolean
|
||||
/**
|
||||
* Whether the edit option is disabled
|
||||
* @default false
|
||||
*/
|
||||
disableEdit?: boolean
|
||||
/**
|
||||
* Whether the delete option is disabled
|
||||
* @default false
|
||||
*/
|
||||
disableDelete?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu component for knowledge base cards.
|
||||
* Displays open in new tab, view tags, edit, and delete options in a popover at the right-click position.
|
||||
*/
|
||||
export function KnowledgeBaseContextMenu({
|
||||
isOpen,
|
||||
position,
|
||||
menuRef,
|
||||
onClose,
|
||||
onOpenInNewTab,
|
||||
onViewTags,
|
||||
onEdit,
|
||||
onDelete,
|
||||
showOpenInNewTab = true,
|
||||
showViewTags = true,
|
||||
showEdit = true,
|
||||
showDelete = true,
|
||||
disableEdit = false,
|
||||
disableDelete = false,
|
||||
}: KnowledgeBaseContextMenuProps) {
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={onClose} variant='secondary' size='sm'>
|
||||
<PopoverAnchor
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
}}
|
||||
/>
|
||||
<PopoverContent ref={menuRef} align='start' side='bottom' sideOffset={4}>
|
||||
{showOpenInNewTab && onOpenInNewTab && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onOpenInNewTab()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Open in new tab
|
||||
</PopoverItem>
|
||||
)}
|
||||
{showViewTags && onViewTags && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onViewTags()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
View tags
|
||||
</PopoverItem>
|
||||
)}
|
||||
{showEdit && onEdit && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
onClick={() => {
|
||||
onEdit()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</PopoverItem>
|
||||
)}
|
||||
{showDelete && onDelete && (
|
||||
<PopoverItem
|
||||
disabled={disableDelete}
|
||||
onClick={() => {
|
||||
onDelete()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</PopoverItem>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ChevronDown, Database, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
@@ -29,7 +30,9 @@ import {
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { useKnowledgeBasesList } from '@/hooks/use-knowledge'
|
||||
import type { KnowledgeBaseData } from '@/stores/knowledge/store'
|
||||
import { type KnowledgeBaseData, useKnowledgeStore } from '@/stores/knowledge/store'
|
||||
|
||||
const logger = createLogger('Knowledge')
|
||||
|
||||
/**
|
||||
* Extended knowledge base data with document count
|
||||
@@ -46,7 +49,7 @@ export function Knowledge() {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const { knowledgeBases, isLoading, error, addKnowledgeBase, refreshList } =
|
||||
const { knowledgeBases, isLoading, error, addKnowledgeBase, removeKnowledgeBase, refreshList } =
|
||||
useKnowledgeBasesList(workspaceId)
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
@@ -85,6 +88,65 @@ export function Knowledge() {
|
||||
refreshList()
|
||||
}
|
||||
|
||||
const { updateKnowledgeBase: updateKnowledgeBaseInStore } = useKnowledgeStore()
|
||||
|
||||
/**
|
||||
* Updates a knowledge base name and description
|
||||
*/
|
||||
const handleUpdateKnowledgeBase = useCallback(
|
||||
async (id: string, name: string, description: string) => {
|
||||
const response = await fetch(`/api/knowledge/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name, description }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Failed to update knowledge base')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
logger.info(`Knowledge base updated: ${id}`)
|
||||
updateKnowledgeBaseInStore(id, { name, description })
|
||||
await refreshList()
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to update knowledge base')
|
||||
}
|
||||
},
|
||||
[refreshList, updateKnowledgeBaseInStore]
|
||||
)
|
||||
|
||||
/**
|
||||
* Deletes a knowledge base
|
||||
*/
|
||||
const handleDeleteKnowledgeBase = useCallback(
|
||||
async (id: string) => {
|
||||
const response = await fetch(`/api/knowledge/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Failed to delete knowledge base')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
logger.info(`Knowledge base deleted: ${id}`)
|
||||
removeKnowledgeBase(id)
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to delete knowledge base')
|
||||
}
|
||||
},
|
||||
[removeKnowledgeBase]
|
||||
)
|
||||
|
||||
/**
|
||||
* Filter and sort knowledge bases based on search query and sort options
|
||||
* Memoized to prevent unnecessary recalculations on render
|
||||
@@ -234,6 +296,8 @@ export function Knowledge() {
|
||||
description={displayData.description}
|
||||
createdAt={displayData.createdAt}
|
||||
updatedAt={displayData.updatedAt}
|
||||
onUpdate={handleUpdateKnowledgeBase}
|
||||
onDelete={handleDeleteKnowledgeBase}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
|
||||
import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal'
|
||||
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal/invite-modal'
|
||||
@@ -27,6 +26,7 @@ interface Workspace {
|
||||
name: string
|
||||
ownerId: string
|
||||
role?: string
|
||||
permissions?: 'admin' | 'write' | 'read' | null
|
||||
}
|
||||
|
||||
interface WorkspaceHeaderProps {
|
||||
@@ -128,7 +128,6 @@ export function WorkspaceHeader({
|
||||
isImportingWorkspace,
|
||||
showCollapseButton = true,
|
||||
}: WorkspaceHeaderProps) {
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false)
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
@@ -138,13 +137,15 @@ export function WorkspaceHeader({
|
||||
const [isListRenaming, setIsListRenaming] = useState(false)
|
||||
const listRenameInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
// Context menu state
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 })
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false)
|
||||
const contextMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const capturedWorkspaceRef = useRef<{ id: string; name: string } | null>(null)
|
||||
const capturedWorkspaceRef = useRef<{
|
||||
id: string
|
||||
name: string
|
||||
permissions?: 'admin' | 'write' | 'read' | null
|
||||
} | null>(null)
|
||||
|
||||
// Client-only rendering for Popover to prevent Radix ID hydration mismatch
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
useEffect(() => {
|
||||
setIsMounted(true)
|
||||
@@ -186,7 +187,11 @@ export function WorkspaceHeader({
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
capturedWorkspaceRef.current = { id: workspace.id, name: workspace.name }
|
||||
capturedWorkspaceRef.current = {
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
permissions: workspace.permissions,
|
||||
}
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY })
|
||||
setIsContextMenuOpen(true)
|
||||
}
|
||||
@@ -467,23 +472,31 @@ export function WorkspaceHeader({
|
||||
</div>
|
||||
|
||||
{/* Context Menu */}
|
||||
<ContextMenu
|
||||
isOpen={isContextMenuOpen}
|
||||
position={contextMenuPosition}
|
||||
menuRef={contextMenuRef}
|
||||
onClose={closeContextMenu}
|
||||
onRename={handleRenameAction}
|
||||
onDuplicate={handleDuplicateAction}
|
||||
onExport={handleExportAction}
|
||||
onDelete={handleDeleteAction}
|
||||
showRename={true}
|
||||
showDuplicate={true}
|
||||
showExport={true}
|
||||
disableRename={!userPermissions.canEdit}
|
||||
disableDuplicate={!userPermissions.canEdit}
|
||||
disableExport={!userPermissions.canAdmin}
|
||||
disableDelete={!userPermissions.canAdmin}
|
||||
/>
|
||||
{(() => {
|
||||
const capturedPermissions = capturedWorkspaceRef.current?.permissions
|
||||
const contextCanEdit = capturedPermissions === 'admin' || capturedPermissions === 'write'
|
||||
const contextCanAdmin = capturedPermissions === 'admin'
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
isOpen={isContextMenuOpen}
|
||||
position={contextMenuPosition}
|
||||
menuRef={contextMenuRef}
|
||||
onClose={closeContextMenu}
|
||||
onRename={handleRenameAction}
|
||||
onDuplicate={handleDuplicateAction}
|
||||
onExport={handleExportAction}
|
||||
onDelete={handleDeleteAction}
|
||||
showRename={true}
|
||||
showDuplicate={true}
|
||||
showExport={true}
|
||||
disableRename={!contextCanEdit}
|
||||
disableDuplicate={!contextCanEdit}
|
||||
disableExport={!contextCanAdmin}
|
||||
disableDelete={!contextCanAdmin}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Invite Modal */}
|
||||
<InviteModal
|
||||
|
||||
Reference in New Issue
Block a user