Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/docs/content/docs/en/integrations/knowledge.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Search for similar content in a knowledge base using vector similarity
| `knowledgeBaseId` | string | Yes | ID of the knowledge base to search in |
| `query` | string | No | Search query text \(optional when using tag filters\) |
| `topK` | number | No | Number of most similar results to return \(1-100\) |
| `tagFilters` | array | No | Array of tag filters with tagName and tagValue properties |
| `tagFilters` | array | No | Array of tag filters using either tagName or tagId together with tagValue properties |
| `searchMode` | string | No | Retrieval mode: 'vector' \(default\) uses semantic similarity only, 'hybrid' also runs a full-text leg and fuses both |
| `rerankerEnabled` | boolean | No | Whether to apply Cohere reranking to vector search results |
| `rerankerModel` | string | No | Cohere rerank model to use \(one of: rerank-v4.0-pro, rerank-v4.0-fast, rerank-v3.5\) |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* @vitest-environment jsdom
*/
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockCopy, mockRefetchTagUsage } = vi.hoisted(() => ({
mockCopy: vi.fn(),
mockRefetchTagUsage: vi.fn(),
}))

vi.mock('@sim/emcn', async () => {
const { useState } = await import('react')

const Container = ({ children }: { children?: ReactNode }) => <div>{children}</div>

return {
Button: ({
children,
variant: _variant,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: string }) => (
<button {...props}>{children}</button>
),
ChipCombobox: () => null,
ChipConfirmModal: ({ open, children }: { open: boolean; children?: ReactNode }) =>
open ? <div>{children}</div> : null,
ChipInput: () => null,
ChipModal: ({ open, children }: { open: boolean; children?: ReactNode }) =>
open ? <div>{children}</div> : null,
ChipModalBody: Container,
ChipModalField: ({ children, title }: { children?: ReactNode; title?: ReactNode }) => (
<div>
{title}
{children}
</div>
),
ChipModalFooter: Container,
ChipModalHeader: Container,
handleKeyboardActivation: vi.fn(),
Tooltip: {
Root: Container,
Trigger: Container,
Content: Container,
},
useCopyToClipboard: () => {
const [copied, setCopied] = useState(false)
return {
copied,
copy: async (text: string) => {
await mockCopy(text)
setCopied(true)
return true
},
}
},
}
})

vi.mock('@sim/emcn/icons', () => ({
Check: (props: React.SVGProps<SVGSVGElement>) => <svg data-icon='check' {...props} />,
Duplicate: (props: React.SVGProps<SVGSVGElement>) => <svg data-icon='duplicate' {...props} />,
Trash: (props: React.SVGProps<SVGSVGElement>) => <svg data-icon='trash' {...props} />,
}))

vi.mock('@/app/workspace/[workspaceId]/knowledge/components', () => ({
getDocumentIcon: () => (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />,
}))

vi.mock('@/hooks/kb/use-knowledge-base-tag-definitions', () => ({
useKnowledgeBaseTagDefinitions: () => ({
tagDefinitions: [
{
id: 'tag-definition-uuid',
tagSlot: 'tag1',
displayName: 'category',
fieldType: 'text',
},
],
}),
}))

vi.mock('@/hooks/queries/kb/knowledge', () => ({
useCreateTagDefinition: () => ({ isPending: false, mutateAsync: vi.fn() }),
useDeleteTagDefinition: () => ({ isPending: false, mutateAsync: vi.fn() }),
useTagUsageQuery: () => ({
data: [
{
tagName: 'category',
tagSlot: 'tag1',
documentCount: 0,
documents: [],
},
],
refetch: mockRefetchTagUsage,
}),
}))

import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/base-tags-modal/base-tags-modal'

let container: HTMLDivElement
let root: Root

describe('BaseTagsModal tag ID copy control', () => {
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
mockCopy.mockResolvedValue(undefined)
})

afterEach(() => {
act(() => root.unmount())
container.remove()
vi.clearAllMocks()
})

it('copies the tag UUID, shows feedback, and does not open tag usage', async () => {
await act(async () => {
root.render(<BaseTagsModal open onOpenChange={vi.fn()} knowledgeBaseId='knowledge-base-id' />)
})

const copyButton = container.querySelector(
'button[aria-label="Copy category tag ID"]'
) as HTMLButtonElement
const deleteButton = container.querySelector(
'button[aria-label="Delete category tag"]'
) as HTMLButtonElement

expect(copyButton).toBeTruthy()
expect(deleteButton).toBeTruthy()
expect(copyButton.querySelector('[data-icon="duplicate"]')).toBeTruthy()
expect(
copyButton.compareDocumentPosition(deleteButton) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy()

await act(async () => {
copyButton.dispatchEvent(new MouseEvent('click', { bubbles: true }))
})

expect(mockCopy).toHaveBeenCalledWith('tag-definition-uuid')
expect(mockRefetchTagUsage).not.toHaveBeenCalled()
expect(container.textContent).toContain('Copied')
expect(copyButton.querySelector('svg')?.classList.contains('text-[var(--text-success)]')).toBe(
true
)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ import {
ChipModalHeader,
type ComboboxOption,
handleKeyboardActivation,
Tooltip,
useCopyToClipboard,
} from '@sim/emcn'
import { Trash } from '@sim/emcn/icons'
import { Check, Duplicate, Trash } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import type { TagUsageData } from '@/lib/api/contracts/knowledge'
import {
Expand Down Expand Up @@ -97,6 +99,8 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
displayName: '',
fieldType: 'text',
})
const [copiedTagId, setCopiedTagId] = useState<string | null>(null)
const { copied, copy } = useCopyToClipboard()

const { data: tagUsageData = [], refetch: refetchTagUsage } = useTagUsageQuery(knowledgeBaseId, {
enabled: open,
Expand Down Expand Up @@ -125,6 +129,12 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
setViewDocumentsDialogOpen(true)
}

const handleCopyTagId = async (tagId: string) => {
if (await copy(tagId)) {
setCopiedTagId(tagId)
}
}

const openTagCreator = () => {
setCreateTagForm({
displayName: '',
Expand Down Expand Up @@ -291,13 +301,38 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
{usage.documentCount} document{usage.documentCount !== 1 ? 's' : ''}
</span>
<div className='flex flex-shrink-0 items-center gap-1'>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
type='button'
variant='ghost'
onClick={(e) => {
e.stopPropagation()
void handleCopyTagId(tag.id)
}}
className='size-4 p-0 text-[var(--text-muted)]'
aria-label={`Copy ${tag.displayName} tag ID`}
>
{copied && copiedTagId === tag.id ? (
<Check className='size-3 text-[var(--text-success)]' />
) : (
<Duplicate className='size-3' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
{copied && copiedTagId === tag.id ? 'Copied' : 'Copy tag ID'}
</Tooltip.Content>
</Tooltip.Root>
<Button
type='button'
variant='ghost'
onClick={(e) => {
e.stopPropagation()
handleDeleteTagClick(tag)
}}
className='size-4 p-0 text-[var(--text-muted)] hover-hover:text-[var(--text-error)]'
aria-label={`Delete ${tag.displayName} tag`}
>
<Trash className='size-3' />
</Button>
Expand Down
Loading
Loading