feat(kb): added sort ordering to knowledgebase page, styling update (#1748)

* remove extraneous text from careers app

* feat(kb): added sort order to kb

* updated styles of workspace selector and delete button to match theme of rest of knowledgebase
This commit is contained in:
Waleed
2025-10-27 21:39:48 -07:00
committed by GitHub
parent 9991796661
commit 9df886d1e9
8 changed files with 362 additions and 156 deletions

View File

@@ -708,32 +708,30 @@ export function KnowledgeBase({
<div className='flex-1 overflow-auto'>
<div className='px-6 pb-6'>
{/* Search and Filters Section */}
<div className='mb-4 space-y-3 pt-1'>
<div className='flex items-center justify-between'>
<SearchInput
value={searchQuery}
onChange={handleSearchChange}
placeholder='Search documents...'
isLoading={isLoadingDocuments}
/>
<div className='mb-4 flex items-center justify-between pt-1'>
<SearchInput
value={searchQuery}
onChange={handleSearchChange}
placeholder='Search documents...'
isLoading={isLoadingDocuments}
/>
<div className='flex items-center gap-3'>
{/* Add Documents Button */}
<Tooltip>
<TooltipTrigger asChild>
<PrimaryButton
onClick={handleAddDocuments}
disabled={userPermissions.canEdit !== true}
>
<Plus className='h-3.5 w-3.5' />
Add Documents
</PrimaryButton>
</TooltipTrigger>
{userPermissions.canEdit !== true && (
<TooltipContent>Write permission required to add documents</TooltipContent>
)}
</Tooltip>
</div>
<div className='flex items-center gap-2'>
{/* Add Documents Button */}
<Tooltip>
<TooltipTrigger asChild>
<PrimaryButton
onClick={handleAddDocuments}
disabled={userPermissions.canEdit !== true}
>
<Plus className='h-3.5 w-3.5' />
Add Documents
</PrimaryButton>
</TooltipTrigger>
{userPermissions.canEdit !== true && (
<TooltipContent>Write permission required to add documents</TooltipContent>
)}
</Tooltip>
</div>
</div>

View File

@@ -10,14 +10,65 @@ interface BaseOverviewProps {
title: string
docCount: number
description: string
createdAt?: string
updatedAt?: string
}
export function BaseOverview({ id, title, docCount, description }: BaseOverviewProps) {
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) {
return 'just now'
}
if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60)
return `${minutes}m ago`
}
if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600)
return `${hours}h ago`
}
if (diffInSeconds < 604800) {
const days = Math.floor(diffInSeconds / 86400)
return `${days}d ago`
}
if (diffInSeconds < 2592000) {
const weeks = Math.floor(diffInSeconds / 604800)
return `${weeks}w ago`
}
if (diffInSeconds < 31536000) {
const months = Math.floor(diffInSeconds / 2592000)
return `${months}mo ago`
}
const years = Math.floor(diffInSeconds / 31536000)
return `${years}y ago`
}
function formatAbsoluteDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
export function BaseOverview({
id,
title,
docCount,
description,
createdAt,
updatedAt,
}: BaseOverviewProps) {
const [isCopied, setIsCopied] = useState(false)
const params = useParams()
const workspaceId = params?.workspaceId as string
// Create URL with knowledge base name as query parameter
const searchParams = new URLSearchParams({
kbName: title,
})
@@ -63,6 +114,23 @@ export function BaseOverview({ id, title, docCount, description }: BaseOverviewP
</div>
</div>
{/* Timestamps */}
{(createdAt || updatedAt) && (
<div className='flex items-center gap-2 text-muted-foreground text-xs'>
{updatedAt && (
<span title={`Last updated: ${formatAbsoluteDate(updatedAt)}`}>
Updated {formatRelativeTime(updatedAt)}
</span>
)}
{updatedAt && createdAt && <span></span>}
{createdAt && (
<span title={`Created: ${formatAbsoluteDate(createdAt)}`}>
Created {formatRelativeTime(createdAt)}
</span>
)}
</div>
)}
<p className='line-clamp-2 overflow-hidden text-muted-foreground text-xs'>
{description}
</p>

View File

@@ -10,6 +10,11 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { WorkspaceSelector } from '@/app/workspace/[workspaceId]/knowledge/components'
import {
commandListClass,
dropdownContentClass,
filterButtonClass,
} from '@/app/workspace/[workspaceId]/knowledge/components/shared'
interface BreadcrumbItem {
label: string
@@ -24,8 +29,7 @@ const HEADER_STYLES = {
link: 'group flex items-center gap-2 font-medium text-sm transition-colors hover:text-muted-foreground',
label: 'font-medium text-sm',
separator: 'text-muted-foreground',
// Always reserve consistent space for actions area
actionsContainer: 'flex h-8 items-center justify-center gap-2',
actionsContainer: 'flex items-center gap-2',
} as const
interface KnowledgeHeaderOptions {
@@ -66,42 +70,52 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
})}
</div>
{/* Actions Area - always reserve consistent space */}
<div className={HEADER_STYLES.actionsContainer}>
{/* Workspace Selector */}
{options?.knowledgeBaseId && (
<WorkspaceSelector
knowledgeBaseId={options.knowledgeBaseId}
currentWorkspaceId={options.currentWorkspaceId || null}
onWorkspaceChange={options.onWorkspaceChange}
/>
)}
{/* Actions Area */}
{options && (
<div className={HEADER_STYLES.actionsContainer}>
{/* Workspace Selector */}
{options.knowledgeBaseId && (
<WorkspaceSelector
knowledgeBaseId={options.knowledgeBaseId}
currentWorkspaceId={options.currentWorkspaceId || null}
onWorkspaceChange={options.onWorkspaceChange}
/>
)}
{/* Actions Menu */}
{options?.onDeleteKnowledgeBase && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-8 w-8 p-0'
aria-label='Knowledge base actions menu'
{/* Actions Menu */}
{options.onDeleteKnowledgeBase && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='sm'
className={filterButtonClass}
aria-label='Knowledge base actions menu'
>
<MoreHorizontal className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='end'
side='bottom'
avoidCollisions={false}
sideOffset={4}
className={dropdownContentClass}
>
<MoreHorizontal className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem
onClick={options.onDeleteKnowledgeBase}
className='text-red-600 focus:text-red-600'
>
<Trash2 className='mr-2 h-4 w-4' />
Delete Knowledge Base
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className={`${commandListClass} py-1`}>
<DropdownMenuItem
onClick={options.onDeleteKnowledgeBase}
className='flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 font-[380] text-red-600 text-sm hover:bg-secondary/50 focus:bg-secondary/50 focus:text-red-600'
>
<Trash2 className='h-4 w-4' />
Delete Knowledge Base
</DropdownMenuItem>
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,20 @@
export const filterButtonClass =
'w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[#FFFFFF] font-normal text-sm dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
export const dropdownContentClass =
'w-[220px] rounded-lg border-[#E5E5E5] bg-[#FFFFFF] p-0 shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
export const commandListClass = 'overflow-y-auto overflow-x-hidden'
export type SortOption = 'name' | 'createdAt' | 'updatedAt' | 'docCount'
export type SortOrder = 'asc' | 'desc'
export const SORT_OPTIONS = [
{ value: 'updatedAt-desc', label: 'Last Updated' },
{ value: 'createdAt-desc', label: 'Newest First' },
{ value: 'createdAt-asc', label: 'Oldest First' },
{ value: 'name-asc', label: 'Name (A-Z)' },
{ value: 'name-desc', label: 'Name (Z-A)' },
{ value: 'docCount-desc', label: 'Most Documents' },
{ value: 'docCount-asc', label: 'Least Documents' },
] as const

View File

@@ -11,6 +11,11 @@ import {
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console/logger'
import {
commandListClass,
dropdownContentClass,
filterButtonClass,
} from '@/app/workspace/[workspaceId]/knowledge/components/shared'
import { useKnowledgeStore } from '@/stores/knowledge/store'
const logger = createLogger('WorkspaceSelector')
@@ -132,53 +137,65 @@ export function WorkspaceSelector({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
variant='outline'
size='sm'
disabled={disabled || isLoading || isUpdating}
className='h-8 gap-1 px-2 text-muted-foreground text-xs hover:text-foreground'
className={filterButtonClass}
>
<span className='max-w-[120px] truncate'>
<span className='truncate'>
{isLoading
? 'Loading...'
: isUpdating
? 'Updating...'
: currentWorkspace?.name || 'No workspace'}
</span>
<ChevronDown className='h-3 w-3' />
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-48'>
{/* No workspace option */}
<DropdownMenuItem
onClick={() => handleWorkspaceChange(null)}
className='flex items-center justify-between'
>
<span className='text-muted-foreground'>No workspace</span>
{!currentWorkspaceId && <Check className='h-4 w-4' />}
</DropdownMenuItem>
{/* Available workspaces */}
{workspaces.map((workspace) => (
<DropdownMenuContent
align='end'
side='bottom'
avoidCollisions={false}
sideOffset={4}
className={dropdownContentClass}
>
<div className={`${commandListClass} py-1`}>
{/* No workspace option */}
<DropdownMenuItem
key={workspace.id}
onClick={() => handleWorkspaceChange(workspace.id)}
className='flex items-center justify-between'
onClick={() => handleWorkspaceChange(null)}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<div className='flex flex-col'>
<span>{workspace.name}</span>
<span className='text-muted-foreground text-xs capitalize'>
{workspace.permissions}
</span>
</div>
{currentWorkspaceId === workspace.id && <Check className='h-4 w-4' />}
<span className='text-muted-foreground'>No workspace</span>
{!currentWorkspaceId && <Check className='h-4 w-4 text-muted-foreground' />}
</DropdownMenuItem>
))}
{workspaces.length === 0 && !isLoading && (
<DropdownMenuItem disabled>
<span className='text-muted-foreground text-xs'>No workspaces with write access</span>
</DropdownMenuItem>
)}
{/* Available workspaces */}
{workspaces.map((workspace) => (
<DropdownMenuItem
key={workspace.id}
onClick={() => handleWorkspaceChange(workspace.id)}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<div className='flex flex-col'>
<span>{workspace.name}</span>
<span className='text-muted-foreground text-xs capitalize'>
{workspace.permissions}
</span>
</div>
{currentWorkspaceId === workspace.id && (
<Check className='h-4 w-4 text-muted-foreground' />
)}
</DropdownMenuItem>
))}
{workspaces.length === 0 && !isLoading && (
<DropdownMenuItem disabled className='px-3 py-2'>
<span className='text-muted-foreground text-xs'>
No workspaces with write access
</span>
</DropdownMenuItem>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@@ -1,8 +1,16 @@
'use client'
import { useMemo, useState } from 'react'
import { LibraryBig, Plus } from 'lucide-react'
import { Check, ChevronDown, LibraryBig, Plus } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import {
BaseOverview,
@@ -13,6 +21,18 @@ import {
PrimaryButton,
SearchInput,
} from '@/app/workspace/[workspaceId]/knowledge/components'
import {
commandListClass,
dropdownContentClass,
filterButtonClass,
SORT_OPTIONS,
type SortOption,
type SortOrder,
} from '@/app/workspace/[workspaceId]/knowledge/components/shared'
import {
filterKnowledgeBases,
sortKnowledgeBases,
} from '@/app/workspace/[workspaceId]/knowledge/utils/sort'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useKnowledgeBasesList } from '@/hooks/use-knowledge'
import type { KnowledgeBaseData } from '@/stores/knowledge/store'
@@ -31,6 +51,18 @@ export function Knowledge() {
const [searchQuery, setSearchQuery] = useState('')
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
const [sortBy, setSortBy] = useState<SortOption>('updatedAt')
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
const currentSortValue = `${sortBy}-${sortOrder}`
const currentSortLabel =
SORT_OPTIONS.find((opt) => opt.value === currentSortValue)?.label || 'Last Updated'
const handleSortChange = (value: string) => {
const [field, order] = value.split('-') as [SortOption, SortOrder]
setSortBy(field)
setSortOrder(order)
}
const handleKnowledgeBaseCreated = (newKnowledgeBase: KnowledgeBaseData) => {
addKnowledgeBase(newKnowledgeBase)
@@ -40,20 +72,18 @@ export function Knowledge() {
refreshList()
}
const filteredKnowledgeBases = useMemo(() => {
if (!searchQuery.trim()) return knowledgeBases
const query = searchQuery.toLowerCase()
return knowledgeBases.filter(
(kb) => kb.name.toLowerCase().includes(query) || kb.description?.toLowerCase().includes(query)
)
}, [knowledgeBases, searchQuery])
const filteredAndSortedKnowledgeBases = useMemo(() => {
const filtered = filterKnowledgeBases(knowledgeBases, searchQuery)
return sortKnowledgeBases(filtered, sortBy, sortOrder)
}, [knowledgeBases, searchQuery, sortBy, sortOrder])
const formatKnowledgeBaseForDisplay = (kb: KnowledgeBaseWithDocCount) => ({
id: kb.id,
title: kb.name,
docCount: kb.docCount || 0,
description: kb.description || 'No description provided',
createdAt: kb.createdAt,
updatedAt: kb.updatedAt,
})
const breadcrumbs = [{ id: 'knowledge', label: 'Knowledge' }]
@@ -77,22 +107,59 @@ export function Knowledge() {
placeholder='Search knowledge bases...'
/>
<Tooltip>
<TooltipTrigger asChild>
<PrimaryButton
onClick={() => setIsCreateModalOpen(true)}
disabled={userPermissions.canEdit !== true}
<div className='flex items-center gap-2'>
{/* Sort Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='sm' className={filterButtonClass}>
{currentSortLabel}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='end'
side='bottom'
avoidCollisions={false}
sideOffset={4}
className={dropdownContentClass}
>
<Plus className='h-3.5 w-3.5' />
<span>Create</span>
</PrimaryButton>
</TooltipTrigger>
{userPermissions.canEdit !== true && (
<TooltipContent>
Write permission required to create knowledge bases
</TooltipContent>
)}
</Tooltip>
<div className={`${commandListClass} py-1`}>
{SORT_OPTIONS.map((option, index) => (
<div key={option.value}>
<DropdownMenuItem
onSelect={() => handleSortChange(option.value)}
className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
>
<span>{option.label}</span>
{currentSortValue === option.value && (
<Check className='h-4 w-4 text-muted-foreground' />
)}
</DropdownMenuItem>
{index === 0 && <DropdownMenuSeparator />}
</div>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Create Button */}
<Tooltip>
<TooltipTrigger asChild>
<PrimaryButton
onClick={() => setIsCreateModalOpen(true)}
disabled={userPermissions.canEdit !== true}
>
<Plus className='h-3.5 w-3.5' />
<span>Create</span>
</PrimaryButton>
</TooltipTrigger>
{userPermissions.canEdit !== true && (
<TooltipContent>
Write permission required to create knowledge bases
</TooltipContent>
)}
</Tooltip>
</div>
</div>
{/* Error State */}
@@ -113,7 +180,7 @@ export function Knowledge() {
<KnowledgeBaseCardSkeletonGrid count={8} />
) : (
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
{filteredKnowledgeBases.length === 0 ? (
{filteredAndSortedKnowledgeBases.length === 0 ? (
knowledgeBases.length === 0 ? (
<EmptyStateCard
title='Create your first knowledge base'
@@ -142,7 +209,7 @@ export function Knowledge() {
</div>
)
) : (
filteredKnowledgeBases.map((kb) => {
filteredAndSortedKnowledgeBases.map((kb) => {
const displayData = formatKnowledgeBaseForDisplay(
kb as KnowledgeBaseWithDocCount
)
@@ -153,6 +220,8 @@ export function Knowledge() {
title={displayData.title}
docCount={displayData.docCount}
description={displayData.description}
createdAt={displayData.createdAt}
updatedAt={displayData.updatedAt}
/>
)
})

View File

@@ -0,0 +1,55 @@
import type { KnowledgeBaseData } from '@/stores/knowledge/store'
import type { SortOption, SortOrder } from '../components/shared'
interface KnowledgeBaseWithDocCount extends KnowledgeBaseData {
docCount?: number
}
/**
* Sort knowledge bases by the specified field and order
*/
export function sortKnowledgeBases(
knowledgeBases: KnowledgeBaseData[],
sortBy: SortOption,
sortOrder: SortOrder
): KnowledgeBaseData[] {
return [...knowledgeBases].sort((a, b) => {
let comparison = 0
switch (sortBy) {
case 'name':
comparison = a.name.localeCompare(b.name)
break
case 'createdAt':
comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
break
case 'updatedAt':
comparison = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
break
case 'docCount':
comparison =
((a as KnowledgeBaseWithDocCount).docCount || 0) -
((b as KnowledgeBaseWithDocCount).docCount || 0)
break
}
return sortOrder === 'asc' ? comparison : -comparison
})
}
/**
* Filter knowledge bases by search query
*/
export function filterKnowledgeBases(
knowledgeBases: KnowledgeBaseData[],
searchQuery: string
): KnowledgeBaseData[] {
if (!searchQuery.trim()) {
return knowledgeBases
}
const query = searchQuery.toLowerCase()
return knowledgeBases.filter(
(kb) => kb.name.toLowerCase().includes(query) || kb.description?.toLowerCase().includes(query)
)
}

View File

@@ -72,41 +72,6 @@ export const CareersConfirmationEmail = ({
schedule an initial conversation.
</Text>
<Section
style={{
marginTop: '24px',
marginBottom: '24px',
padding: '20px',
backgroundColor: '#f9f9f9',
borderRadius: '8px',
border: '1px solid #e5e5e5',
}}
>
<Text
style={{
margin: '0 0 12px 0',
fontSize: '16px',
fontWeight: 'bold',
color: '#333333',
}}
>
What Happens Next?
</Text>
<ul
style={{
margin: '0',
padding: '0 0 0 20px',
fontSize: '14px',
color: '#333333',
lineHeight: '1.8',
}}
>
<li>Our team will review your application</li>
<li>If you're a good fit, we'll reach out to schedule an interview</li>
<li>We'll keep you updated throughout the process</li>
</ul>
</Section>
<Text style={baseStyles.paragraph}>
In the meantime, feel free to explore our{' '}
<a