|
3 | 3 | import { useCallback, useMemo, useRef, useState } from 'react' |
4 | 4 | import { createLogger } from '@sim/logger' |
5 | 5 | import { useParams, useRouter } from 'next/navigation' |
6 | | -import { Tooltip } from '@/components/emcn' |
| 6 | +import type { ComboboxOption } from '@/components/emcn' |
| 7 | +import { Combobox, Tooltip } from '@/components/emcn' |
7 | 8 | import { Database } from '@/components/emcn/icons' |
8 | | -import { cn } from '@/lib/core/utils/cn' |
9 | 9 | import type { KnowledgeBaseData } from '@/lib/knowledge/types' |
10 | 10 | import type { |
11 | 11 | CreateAction, |
@@ -106,8 +106,8 @@ export function Knowledge() { |
106 | 106 | column: string |
107 | 107 | direction: 'asc' | 'desc' |
108 | 108 | } | null>(null) |
109 | | - const [connectorFilter, setConnectorFilter] = useState<'all' | 'connected' | 'unconnected'>('all') |
110 | | - const [contentFilter, setContentFilter] = useState<'all' | 'has-docs' | 'empty'>('all') |
| 109 | + const [connectorFilter, setConnectorFilter] = useState<string[]>([]) |
| 110 | + const [contentFilter, setContentFilter] = useState<string[]>([]) |
111 | 111 | const [ownerFilter, setOwnerFilter] = useState<string[]>([]) |
112 | 112 |
|
113 | 113 | const [searchInputValue, setSearchInputValue] = useState('') |
@@ -186,20 +186,22 @@ export function Knowledge() { |
186 | 186 | const processedKBs = useMemo(() => { |
187 | 187 | let result = filterKnowledgeBases(knowledgeBases, debouncedSearchQuery) |
188 | 188 |
|
189 | | - if (connectorFilter !== 'all') { |
190 | | - result = result.filter((kb) => |
191 | | - connectorFilter === 'connected' |
192 | | - ? (kb.connectorTypes?.length ?? 0) > 0 |
193 | | - : (kb.connectorTypes?.length ?? 0) === 0 |
194 | | - ) |
| 189 | + if (connectorFilter.length > 0) { |
| 190 | + result = result.filter((kb) => { |
| 191 | + const hasConnectors = (kb.connectorTypes?.length ?? 0) > 0 |
| 192 | + if (connectorFilter.includes('connected') && hasConnectors) return true |
| 193 | + if (connectorFilter.includes('unconnected') && !hasConnectors) return true |
| 194 | + return false |
| 195 | + }) |
195 | 196 | } |
196 | 197 |
|
197 | | - if (contentFilter !== 'all') { |
198 | | - result = result.filter((kb) => |
199 | | - contentFilter === 'has-docs' |
200 | | - ? ((kb as KnowledgeBaseWithDocCount).docCount ?? 0) > 0 |
201 | | - : ((kb as KnowledgeBaseWithDocCount).docCount ?? 0) === 0 |
202 | | - ) |
| 198 | + if (contentFilter.length > 0) { |
| 199 | + const docCount = (kb: KnowledgeBaseData) => (kb as KnowledgeBaseWithDocCount).docCount ?? 0 |
| 200 | + result = result.filter((kb) => { |
| 201 | + if (contentFilter.includes('has-docs') && docCount(kb) > 0) return true |
| 202 | + if (contentFilter.includes('empty') && docCount(kb) === 0) return true |
| 203 | + return false |
| 204 | + }) |
203 | 205 | } |
204 | 206 |
|
205 | 207 | if (ownerFilter.length > 0) { |
@@ -370,122 +372,142 @@ export function Knowledge() { |
370 | 372 | [activeSort] |
371 | 373 | ) |
372 | 374 |
|
| 375 | + const connectorDisplayLabel = useMemo(() => { |
| 376 | + if (connectorFilter.length === 0) return 'All' |
| 377 | + if (connectorFilter.length === 1) |
| 378 | + return connectorFilter[0] === 'connected' ? 'With connectors' : 'Without connectors' |
| 379 | + return `${connectorFilter.length} selected` |
| 380 | + }, [connectorFilter]) |
| 381 | + |
| 382 | + const contentDisplayLabel = useMemo(() => { |
| 383 | + if (contentFilter.length === 0) return 'All' |
| 384 | + if (contentFilter.length === 1) |
| 385 | + return contentFilter[0] === 'has-docs' ? 'Has documents' : 'Empty' |
| 386 | + return `${contentFilter.length} selected` |
| 387 | + }, [contentFilter]) |
| 388 | + |
| 389 | + const ownerDisplayLabel = useMemo(() => { |
| 390 | + if (ownerFilter.length === 0) return 'All' |
| 391 | + if (ownerFilter.length === 1) |
| 392 | + return members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member' |
| 393 | + return `${ownerFilter.length} members` |
| 394 | + }, [ownerFilter, members]) |
| 395 | + |
| 396 | + const memberOptions: ComboboxOption[] = useMemo( |
| 397 | + () => |
| 398 | + (members ?? []).map((m) => ({ |
| 399 | + value: m.userId, |
| 400 | + label: m.name, |
| 401 | + iconElement: m.image ? ( |
| 402 | + <img |
| 403 | + src={m.image} |
| 404 | + alt={m.name} |
| 405 | + referrerPolicy='no-referrer' |
| 406 | + className='h-[14px] w-[14px] rounded-full border border-[var(--border)] object-cover' |
| 407 | + /> |
| 408 | + ) : ( |
| 409 | + <span className='flex h-[14px] w-[14px] items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'> |
| 410 | + {m.name.charAt(0).toUpperCase()} |
| 411 | + </span> |
| 412 | + ), |
| 413 | + })), |
| 414 | + [members] |
| 415 | + ) |
| 416 | + |
| 417 | + const hasActiveFilters = |
| 418 | + connectorFilter.length > 0 || contentFilter.length > 0 || ownerFilter.length > 0 |
| 419 | + |
373 | 420 | const filterContent = ( |
374 | | - <div className='w-[200px]'> |
375 | | - <div className='border-[var(--border-1)] border-b px-3 py-2'> |
| 421 | + <div className='flex w-[240px] flex-col gap-3 p-3'> |
| 422 | + <div className='flex flex-col gap-1.5'> |
376 | 423 | <span className='font-medium text-[var(--text-secondary)] text-caption'>Connectors</span> |
377 | | - </div> |
378 | | - <div className='flex flex-col gap-0.5 px-3 py-2'> |
379 | | - {( |
380 | | - [ |
381 | | - { value: 'all', label: 'All' }, |
| 424 | + <Combobox |
| 425 | + options={[ |
382 | 426 | { value: 'connected', label: 'With connectors' }, |
383 | 427 | { value: 'unconnected', label: 'Without connectors' }, |
384 | | - ] as const |
385 | | - ).map(({ value, label }) => ( |
386 | | - <button |
387 | | - key={value} |
388 | | - type='button' |
389 | | - className={cn( |
390 | | - 'flex w-full cursor-pointer select-none items-center rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-secondary)] text-caption outline-none transition-colors hover-hover:bg-[var(--surface-active)]', |
391 | | - connectorFilter === value && 'bg-[var(--surface-active)]' |
392 | | - )} |
393 | | - onClick={() => setConnectorFilter(value)} |
394 | | - > |
395 | | - {label} |
396 | | - </button> |
397 | | - ))} |
| 428 | + ]} |
| 429 | + multiSelect |
| 430 | + multiSelectValues={connectorFilter} |
| 431 | + onMultiSelectChange={setConnectorFilter} |
| 432 | + overlayContent={ |
| 433 | + <span className='truncate text-[var(--text-primary)]'>{connectorDisplayLabel}</span> |
| 434 | + } |
| 435 | + showAllOption |
| 436 | + allOptionLabel='All' |
| 437 | + size='sm' |
| 438 | + className='h-[32px] w-full rounded-md' |
| 439 | + /> |
398 | 440 | </div> |
399 | | - <div className='border-[var(--border-1)] border-t border-b px-3 py-2'> |
| 441 | + <div className='flex flex-col gap-1.5'> |
400 | 442 | <span className='font-medium text-[var(--text-secondary)] text-caption'>Content</span> |
401 | | - </div> |
402 | | - <div className='flex flex-col gap-0.5 px-3 py-2'> |
403 | | - {( |
404 | | - [ |
405 | | - { value: 'all', label: 'All' }, |
| 443 | + <Combobox |
| 444 | + options={[ |
406 | 445 | { value: 'has-docs', label: 'Has documents' }, |
407 | 446 | { value: 'empty', label: 'Empty' }, |
408 | | - ] as const |
409 | | - ).map(({ value, label }) => ( |
410 | | - <button |
411 | | - key={value} |
412 | | - type='button' |
413 | | - className={cn( |
414 | | - 'flex w-full cursor-pointer select-none items-center rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-secondary)] text-caption outline-none transition-colors hover-hover:bg-[var(--surface-active)]', |
415 | | - contentFilter === value && 'bg-[var(--surface-active)]' |
416 | | - )} |
417 | | - onClick={() => setContentFilter(value)} |
418 | | - > |
419 | | - {label} |
420 | | - </button> |
421 | | - ))} |
| 447 | + ]} |
| 448 | + multiSelect |
| 449 | + multiSelectValues={contentFilter} |
| 450 | + onMultiSelectChange={setContentFilter} |
| 451 | + overlayContent={ |
| 452 | + <span className='truncate text-[var(--text-primary)]'>{contentDisplayLabel}</span> |
| 453 | + } |
| 454 | + showAllOption |
| 455 | + allOptionLabel='All' |
| 456 | + size='sm' |
| 457 | + className='h-[32px] w-full rounded-md' |
| 458 | + /> |
422 | 459 | </div> |
423 | | - {members && members.length > 0 && ( |
424 | | - <> |
425 | | - <div className='border-[var(--border-1)] border-t border-b px-3 py-2'> |
426 | | - <span className='font-medium text-[var(--text-secondary)] text-caption'>Owner</span> |
427 | | - </div> |
428 | | - <div className='flex flex-col gap-0.5 px-3 py-2'> |
429 | | - <button |
430 | | - type='button' |
431 | | - className={cn( |
432 | | - 'flex w-full cursor-pointer select-none items-center rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-secondary)] text-caption outline-none transition-colors hover-hover:bg-[var(--surface-active)]', |
433 | | - ownerFilter.length === 0 && 'bg-[var(--surface-active)]' |
434 | | - )} |
435 | | - onClick={() => setOwnerFilter([])} |
436 | | - > |
437 | | - All |
438 | | - </button> |
439 | | - {members.map((member) => ( |
440 | | - <button |
441 | | - key={member.userId} |
442 | | - type='button' |
443 | | - className={cn( |
444 | | - 'flex w-full cursor-pointer select-none items-center gap-1.5 rounded-[5px] px-2 py-[5px] font-medium text-[var(--text-secondary)] text-caption outline-none transition-colors hover-hover:bg-[var(--surface-active)]', |
445 | | - ownerFilter.includes(member.userId) && 'bg-[var(--surface-active)]' |
446 | | - )} |
447 | | - onClick={() => |
448 | | - setOwnerFilter((prev) => |
449 | | - prev.includes(member.userId) |
450 | | - ? prev.filter((id) => id !== member.userId) |
451 | | - : [...prev, member.userId] |
452 | | - ) |
453 | | - } |
454 | | - > |
455 | | - {member.image ? ( |
456 | | - <img |
457 | | - src={member.image} |
458 | | - alt={member.name} |
459 | | - referrerPolicy='no-referrer' |
460 | | - className='h-[14px] w-[14px] shrink-0 rounded-full border border-[var(--border)] object-cover' |
461 | | - /> |
462 | | - ) : ( |
463 | | - <span className='flex h-[14px] w-[14px] shrink-0 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'> |
464 | | - {member.name.charAt(0).toUpperCase()} |
465 | | - </span> |
466 | | - )} |
467 | | - <span className='truncate'>{member.name}</span> |
468 | | - </button> |
469 | | - ))} |
470 | | - </div> |
471 | | - </> |
| 460 | + {memberOptions.length > 0 && ( |
| 461 | + <div className='flex flex-col gap-1.5'> |
| 462 | + <span className='font-medium text-[var(--text-secondary)] text-caption'>Owner</span> |
| 463 | + <Combobox |
| 464 | + options={memberOptions} |
| 465 | + multiSelect |
| 466 | + multiSelectValues={ownerFilter} |
| 467 | + onMultiSelectChange={setOwnerFilter} |
| 468 | + overlayContent={ |
| 469 | + <span className='truncate text-[var(--text-primary)]'>{ownerDisplayLabel}</span> |
| 470 | + } |
| 471 | + searchable |
| 472 | + searchPlaceholder='Search members...' |
| 473 | + showAllOption |
| 474 | + allOptionLabel='All' |
| 475 | + size='sm' |
| 476 | + className='h-[32px] w-full rounded-md' |
| 477 | + /> |
| 478 | + </div> |
| 479 | + )} |
| 480 | + {hasActiveFilters && ( |
| 481 | + <button |
| 482 | + type='button' |
| 483 | + onClick={() => { |
| 484 | + setConnectorFilter([]) |
| 485 | + setContentFilter([]) |
| 486 | + setOwnerFilter([]) |
| 487 | + }} |
| 488 | + className='flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]' |
| 489 | + > |
| 490 | + Clear all filters |
| 491 | + </button> |
472 | 492 | )} |
473 | 493 | </div> |
474 | 494 | ) |
475 | 495 |
|
476 | 496 | const filterTags: FilterTag[] = useMemo(() => { |
477 | 497 | const tags: FilterTag[] = [] |
478 | | - if (connectorFilter !== 'all') { |
479 | | - tags.push({ |
480 | | - label: connectorFilter === 'connected' ? 'Connectors: Active' : 'Connectors: None', |
481 | | - onRemove: () => setConnectorFilter('all'), |
482 | | - }) |
| 498 | + if (connectorFilter.length > 0) { |
| 499 | + const label = |
| 500 | + connectorFilter.length === 1 |
| 501 | + ? `Connectors: ${connectorFilter[0] === 'connected' ? 'With connectors' : 'Without connectors'}` |
| 502 | + : `Connectors: ${connectorFilter.length} types` |
| 503 | + tags.push({ label, onRemove: () => setConnectorFilter([]) }) |
483 | 504 | } |
484 | | - if (contentFilter !== 'all') { |
485 | | - tags.push({ |
486 | | - label: contentFilter === 'has-docs' ? 'Content: Has documents' : 'Content: Empty', |
487 | | - onRemove: () => setContentFilter('all'), |
488 | | - }) |
| 505 | + if (contentFilter.length > 0) { |
| 506 | + const label = |
| 507 | + contentFilter.length === 1 |
| 508 | + ? `Content: ${contentFilter[0] === 'has-docs' ? 'Has documents' : 'Empty'}` |
| 509 | + : `Content: ${contentFilter.length} types` |
| 510 | + tags.push({ label, onRemove: () => setContentFilter([]) }) |
489 | 511 | } |
490 | 512 | if (ownerFilter.length > 0) { |
491 | 513 | const label = |
|
0 commit comments