mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
improvement(resources): all outer page structure complete
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { PostHog } from 'posthog-js'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { PostHog } from 'posthog-js'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
|
||||
const logger = createLogger('PostHogProvider')
|
||||
|
||||
@@ -78,22 +78,24 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (isPending) return
|
||||
|
||||
import('posthog-js').then(({ default: posthog }) => {
|
||||
try {
|
||||
if (typeof posthog.identify !== 'function') return
|
||||
import('posthog-js')
|
||||
.then(({ default: posthog }) => {
|
||||
try {
|
||||
if (typeof posthog.identify !== 'function') return
|
||||
|
||||
if (data?.user) {
|
||||
posthog.identify(data.user.id, {
|
||||
email: data.user.email,
|
||||
name: data.user.name,
|
||||
email_verified: data.user.emailVerified,
|
||||
created_at: data.user.createdAt,
|
||||
})
|
||||
} else {
|
||||
posthog.reset()
|
||||
}
|
||||
} catch {}
|
||||
}).catch(() => {})
|
||||
if (data?.user) {
|
||||
posthog.identify(data.user.id, {
|
||||
email: data.user.email,
|
||||
name: data.user.name,
|
||||
email_verified: data.user.emailVerified,
|
||||
created_at: data.user.createdAt,
|
||||
})
|
||||
} else {
|
||||
posthog.reset()
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [data, isPending])
|
||||
|
||||
const value = useMemo<SessionHookResult>(
|
||||
|
||||
@@ -111,14 +111,14 @@ export function Resource({
|
||||
<div className='border-[var(--border)] border-b px-[24px] py-[10px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
{search && (
|
||||
<div className='relative'>
|
||||
<div className='relative flex-1'>
|
||||
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-0 h-[14px] w-[14px] text-[var(--text-muted)]' />
|
||||
<input
|
||||
type='text'
|
||||
value={search.value}
|
||||
onChange={(e) => search.onChange(e.target.value)}
|
||||
placeholder={search.placeholder ?? 'Search...'}
|
||||
className='bg-transparent py-[4px] pl-[24px] font-base text-[12px] text-[var(--text-secondary)] outline-none placeholder:text-[var(--text-subtle)]'
|
||||
className='w-full bg-transparent py-[4px] pl-[24px] font-base text-[12px] text-[var(--text-secondary)] outline-none placeholder:text-[var(--text-subtle)]'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -223,9 +223,9 @@ function EmptyMessage({ title, description }: { title: string; description?: str
|
||||
|
||||
function CellContent({ cell }: { cell: ResourceCell }) {
|
||||
return (
|
||||
<span className='flex items-center gap-[12px] font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||
{cell.icon && <span className='text-[var(--text-subtle)]'>{cell.icon}</span>}
|
||||
<span>{cell.label}</span>
|
||||
<span className='flex min-w-0 items-center gap-[12px] font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||
{cell.icon && <span className='flex-shrink-0 text-[var(--text-subtle)]'>{cell.icon}</span>}
|
||||
<span className='truncate'>{cell.label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,33 +2,21 @@
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { ChevronDown, Database, Search } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Database } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { formatRelativeTime } from '@/lib/core/utils/formatting'
|
||||
import type { KnowledgeBaseData } from '@/lib/knowledge/types'
|
||||
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
|
||||
import { Resource } from '@/app/workspace/[workspaceId]/components'
|
||||
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
|
||||
import {
|
||||
BaseCard,
|
||||
BaseCardSkeletonGrid,
|
||||
CreateBaseModal,
|
||||
DeleteKnowledgeBaseModal,
|
||||
EditKnowledgeBaseModal,
|
||||
KnowledgeBaseContextMenu,
|
||||
KnowledgeListContextMenu,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
type SortOption,
|
||||
type SortOrder,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/components/constants'
|
||||
import {
|
||||
filterKnowledgeBases,
|
||||
sortKnowledgeBases,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/utils/sort'
|
||||
import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/sort'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
|
||||
@@ -37,19 +25,21 @@ import { useDebounce } from '@/hooks/use-debounce'
|
||||
|
||||
const logger = createLogger('Knowledge')
|
||||
|
||||
/**
|
||||
* Extended knowledge base data with document count
|
||||
*/
|
||||
interface KnowledgeBaseWithDocCount extends KnowledgeBaseData {
|
||||
docCount?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Knowledge base list component displaying all knowledge bases in a workspace
|
||||
* Supports filtering by search query and sorting options
|
||||
*/
|
||||
const COLUMNS: ResourceColumn[] = [
|
||||
{ id: 'name', header: 'Name', width: 'w-[35%]' },
|
||||
{ id: 'documents', header: 'Documents', width: 'w-[12%]' },
|
||||
{ id: 'description', header: 'Description', width: 'w-[28%]' },
|
||||
{ id: 'updated', header: 'Updated', width: 'w-[13%]' },
|
||||
{ id: 'id', header: 'ID', width: 'w-[12%]' },
|
||||
]
|
||||
|
||||
export function Knowledge() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const { knowledgeBases, isLoading, error } = useKnowledgeBasesList(workspaceId)
|
||||
@@ -61,9 +51,14 @@ export function Knowledge() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||
const [isSortPopoverOpen, setIsSortPopoverOpen] = useState(false)
|
||||
const [sortBy, setSortBy] = useState<SortOption>('updatedAt')
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('desc')
|
||||
|
||||
const [activeKnowledgeBase, setActiveKnowledgeBase] = useState<KnowledgeBaseWithDocCount | null>(
|
||||
null
|
||||
)
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const [isTagsModalOpen, setIsTagsModalOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const {
|
||||
isOpen: isListContextMenuOpen,
|
||||
@@ -73,46 +68,36 @@ export function Knowledge() {
|
||||
closeMenu: closeListContextMenu,
|
||||
} = useContextMenu()
|
||||
|
||||
/**
|
||||
* Handle context menu on the content area - only show menu when clicking on empty space
|
||||
*/
|
||||
const {
|
||||
isOpen: isRowContextMenuOpen,
|
||||
position: rowContextMenuPosition,
|
||||
menuRef: rowMenuRef,
|
||||
handleContextMenu: handleRowCtxMenu,
|
||||
closeMenu: closeRowContextMenu,
|
||||
} = useContextMenu()
|
||||
|
||||
const handleContentContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
const isOnCard = target.closest('[data-kb-card]')
|
||||
const isOnInteractive = target.closest('button, input, a, [role="button"]')
|
||||
|
||||
if (!isOnCard && !isOnInteractive) {
|
||||
handleListContextMenu(e)
|
||||
if (
|
||||
target.closest('[data-resource-row]') ||
|
||||
target.closest('button, input, a, [role="button"]')
|
||||
) {
|
||||
return
|
||||
}
|
||||
handleListContextMenu(e)
|
||||
},
|
||||
[handleListContextMenu]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handle add knowledge base from context menu
|
||||
*/
|
||||
const handleAddKnowledgeBase = useCallback(() => {
|
||||
setIsCreateModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const currentSortValue = `${sortBy}-${sortOrder}`
|
||||
const currentSortLabel =
|
||||
SORT_OPTIONS.find((opt) => opt.value === currentSortValue)?.label || 'Last Updated'
|
||||
const handleSort = useCallback(() => {}, [])
|
||||
|
||||
/**
|
||||
* Handles sort option change from dropdown
|
||||
*/
|
||||
const handleSortChange = (value: string) => {
|
||||
const [field, order] = value.split('-') as [SortOption, SortOrder]
|
||||
setSortBy(field)
|
||||
setSortOrder(order)
|
||||
setIsSortPopoverOpen(false)
|
||||
}
|
||||
const handleFilter = useCallback(() => {}, [])
|
||||
|
||||
/**
|
||||
* Updates a knowledge base name and description
|
||||
*/
|
||||
const handleUpdateKnowledgeBase = useCallback(
|
||||
async (id: string, name: string, description: string) => {
|
||||
await updateKnowledgeBaseMutation({
|
||||
@@ -124,9 +109,6 @@ export function Knowledge() {
|
||||
[updateKnowledgeBaseMutation]
|
||||
)
|
||||
|
||||
/**
|
||||
* Deletes a knowledge base
|
||||
*/
|
||||
const handleDeleteKnowledgeBase = useCallback(
|
||||
async (id: string) => {
|
||||
await deleteKnowledgeBaseMutation({ knowledgeBaseId: id })
|
||||
@@ -135,30 +117,72 @@ export function Knowledge() {
|
||||
[deleteKnowledgeBaseMutation]
|
||||
)
|
||||
|
||||
/**
|
||||
* Filter and sort knowledge bases based on search query and sort options
|
||||
*/
|
||||
const filteredAndSortedKnowledgeBases = useMemo(() => {
|
||||
const filtered = filterKnowledgeBases(knowledgeBases, debouncedSearchQuery)
|
||||
return sortKnowledgeBases(filtered, sortBy, sortOrder)
|
||||
}, [knowledgeBases, debouncedSearchQuery, sortBy, sortOrder])
|
||||
const filteredKnowledgeBases = useMemo(
|
||||
() => filterKnowledgeBases(knowledgeBases, debouncedSearchQuery),
|
||||
[knowledgeBases, debouncedSearchQuery]
|
||||
)
|
||||
|
||||
/**
|
||||
* Format knowledge base data for display in the card
|
||||
*/
|
||||
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,
|
||||
connectorTypes: kb.connectorTypes ?? [],
|
||||
})
|
||||
const rows: ResourceRow[] = useMemo(
|
||||
() =>
|
||||
filteredKnowledgeBases.map((kb) => {
|
||||
const kbWithCount = kb as KnowledgeBaseWithDocCount
|
||||
return {
|
||||
id: kb.id,
|
||||
cells: {
|
||||
name: {
|
||||
icon: <Database className='h-[14px] w-[14px]' />,
|
||||
label: kb.name,
|
||||
},
|
||||
documents: {
|
||||
label: String(kbWithCount.docCount || 0),
|
||||
},
|
||||
description: {
|
||||
label: kb.description || 'No description',
|
||||
},
|
||||
updated: {
|
||||
label: kb.updatedAt ? formatRelativeTime(kb.updatedAt) : '',
|
||||
},
|
||||
id: {
|
||||
label: `kb-${kb.id.slice(0, 8)}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
}),
|
||||
[filteredKnowledgeBases]
|
||||
)
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(rowId: string) => {
|
||||
if (isRowContextMenuOpen) return
|
||||
const kb = knowledgeBases.find((k) => k.id === rowId)
|
||||
if (!kb) return
|
||||
const urlParams = new URLSearchParams({ kbName: kb.name })
|
||||
router.push(`/workspace/${workspaceId}/knowledge/${rowId}?${urlParams.toString()}`)
|
||||
},
|
||||
[isRowContextMenuOpen, knowledgeBases, router, workspaceId]
|
||||
)
|
||||
|
||||
const handleRowContextMenu = useCallback(
|
||||
(e: React.MouseEvent, rowId: string) => {
|
||||
const kb = knowledgeBases.find((k) => k.id === rowId) as KnowledgeBaseWithDocCount | undefined
|
||||
setActiveKnowledgeBase(kb ?? null)
|
||||
handleRowCtxMenu(e)
|
||||
},
|
||||
[knowledgeBases, handleRowCtxMenu]
|
||||
)
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!activeKnowledgeBase) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await handleDeleteKnowledgeBase(activeKnowledgeBase.id)
|
||||
setIsDeleteModalOpen(false)
|
||||
setActiveKnowledgeBase(null)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}, [activeKnowledgeBase, handleDeleteKnowledgeBase])
|
||||
|
||||
/**
|
||||
* Get empty state content based on current filters
|
||||
*/
|
||||
const emptyState = useMemo(() => {
|
||||
if (debouncedSearchQuery) {
|
||||
return {
|
||||
@@ -166,7 +190,6 @@ export function Knowledge() {
|
||||
description: 'Try a different search term',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'No knowledge bases yet',
|
||||
description:
|
||||
@@ -178,125 +201,37 @@ export function Knowledge() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex h-full flex-1 flex-col'>
|
||||
<div className='flex flex-1 overflow-hidden'>
|
||||
<div
|
||||
className='flex flex-1 flex-col overflow-auto bg-white px-[24px] pt-[28px] pb-[24px] dark:bg-[var(--bg)]'
|
||||
onContextMenu={handleContentContextMenu}
|
||||
>
|
||||
<div>
|
||||
<div className='flex items-start gap-[12px]'>
|
||||
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#5BB377] bg-[#E8F7EE] dark:border-[#1E5A3E] dark:bg-[#0F3D2C]'>
|
||||
<Database className='h-[14px] w-[14px] text-[#5BB377] dark:text-[#34D399]' />
|
||||
</div>
|
||||
<h1 className='font-medium text-[18px]'>Knowledge Base</h1>
|
||||
</div>
|
||||
<p className='mt-[10px] text-[14px] text-[var(--text-tertiary)]'>
|
||||
Create and manage knowledge bases with custom files.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='mt-[14px] flex items-center justify-between'>
|
||||
<div className='flex h-[32px] w-[400px] items-center gap-[6px] rounded-[8px] bg-[var(--surface-4)] px-[8px]'>
|
||||
<Search className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
|
||||
<Input
|
||||
placeholder='Search'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className='flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
{knowledgeBases.length > 0 && (
|
||||
<Popover open={isSortPopoverOpen} onOpenChange={setIsSortPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant='default' className='h-[32px] rounded-[6px]'>
|
||||
{currentSortLabel}
|
||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align='end' side='bottom' sideOffset={4}>
|
||||
<div className='flex flex-col gap-[2px]'>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<PopoverItem
|
||||
key={option.value}
|
||||
active={currentSortValue === option.value}
|
||||
onClick={() => handleSortChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</PopoverItem>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
disabled={userPermissions.canEdit !== true}
|
||||
variant='tertiary'
|
||||
className='h-[32px] rounded-[6px]'
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
{userPermissions.canEdit !== true && (
|
||||
<Tooltip.Content>
|
||||
Write permission required to create knowledge bases
|
||||
</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-[24px] grid grid-cols-1 gap-[20px] md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||
{isLoading ? (
|
||||
<BaseCardSkeletonGrid count={8} />
|
||||
) : filteredAndSortedKnowledgeBases.length === 0 ? (
|
||||
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
|
||||
<div className='text-center'>
|
||||
<p className='font-medium text-[var(--text-secondary)] text-sm'>
|
||||
{emptyState.title}
|
||||
</p>
|
||||
<p className='mt-1 text-[var(--text-muted)] text-xs'>
|
||||
{emptyState.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
|
||||
<div className='text-center'>
|
||||
<p className='font-medium text-[var(--text-secondary)] text-sm'>
|
||||
Error loading knowledge bases
|
||||
</p>
|
||||
<p className='mt-1 text-[var(--text-muted)] text-xs'>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
filteredAndSortedKnowledgeBases.map((kb) => {
|
||||
const displayData = formatKnowledgeBaseForDisplay(kb as KnowledgeBaseWithDocCount)
|
||||
return (
|
||||
<BaseCard
|
||||
key={kb.id}
|
||||
id={displayData.id}
|
||||
title={displayData.title}
|
||||
docCount={displayData.docCount}
|
||||
description={displayData.description}
|
||||
connectorTypes={displayData.connectorTypes}
|
||||
createdAt={displayData.createdAt}
|
||||
updatedAt={displayData.updatedAt}
|
||||
onUpdate={handleUpdateKnowledgeBase}
|
||||
onDelete={handleDeleteKnowledgeBase}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Resource
|
||||
icon={Database}
|
||||
title='Knowledge Base'
|
||||
create={{
|
||||
label: 'Create',
|
||||
onClick: () => setIsCreateModalOpen(true),
|
||||
disabled: userPermissions.canEdit !== true,
|
||||
}}
|
||||
search={{
|
||||
value: searchQuery,
|
||||
onChange: setSearchQuery,
|
||||
placeholder: 'Search knowledge bases...',
|
||||
}}
|
||||
onSort={handleSort}
|
||||
onFilter={handleFilter}
|
||||
columns={COLUMNS}
|
||||
rows={rows}
|
||||
onRowClick={handleRowClick}
|
||||
onRowContextMenu={handleRowContextMenu}
|
||||
isLoading={isLoading}
|
||||
error={
|
||||
error
|
||||
? {
|
||||
title: 'Error loading knowledge bases',
|
||||
description: error,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
emptyState={emptyState}
|
||||
onContextMenu={handleContentContextMenu}
|
||||
/>
|
||||
|
||||
<KnowledgeListContextMenu
|
||||
isOpen={isListContextMenuOpen}
|
||||
@@ -307,6 +242,64 @@ export function Knowledge() {
|
||||
disableAdd={userPermissions.canEdit !== true}
|
||||
/>
|
||||
|
||||
{activeKnowledgeBase && (
|
||||
<KnowledgeBaseContextMenu
|
||||
isOpen={isRowContextMenuOpen}
|
||||
position={rowContextMenuPosition}
|
||||
menuRef={rowMenuRef}
|
||||
onClose={closeRowContextMenu}
|
||||
onOpenInNewTab={() => {
|
||||
const urlParams = new URLSearchParams({ kbName: activeKnowledgeBase.name })
|
||||
window.open(
|
||||
`/workspace/${workspaceId}/knowledge/${activeKnowledgeBase.id}?${urlParams.toString()}`,
|
||||
'_blank'
|
||||
)
|
||||
}}
|
||||
onViewTags={() => setIsTagsModalOpen(true)}
|
||||
onCopyId={() => navigator.clipboard.writeText(activeKnowledgeBase.id)}
|
||||
onEdit={() => setIsEditModalOpen(true)}
|
||||
onDelete={() => setIsDeleteModalOpen(true)}
|
||||
showOpenInNewTab
|
||||
showViewTags
|
||||
showEdit
|
||||
showDelete
|
||||
disableEdit={!userPermissions.canEdit}
|
||||
disableDelete={!userPermissions.canEdit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeKnowledgeBase && (
|
||||
<EditKnowledgeBaseModal
|
||||
open={isEditModalOpen}
|
||||
onOpenChange={setIsEditModalOpen}
|
||||
knowledgeBaseId={activeKnowledgeBase.id}
|
||||
initialName={activeKnowledgeBase.name}
|
||||
initialDescription={activeKnowledgeBase.description || ''}
|
||||
onSave={handleUpdateKnowledgeBase}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeKnowledgeBase && (
|
||||
<DeleteKnowledgeBaseModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => {
|
||||
setIsDeleteModalOpen(false)
|
||||
setActiveKnowledgeBase(null)
|
||||
}}
|
||||
onConfirm={handleConfirmDelete}
|
||||
isDeleting={isDeleting}
|
||||
knowledgeBaseName={activeKnowledgeBase.name}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeKnowledgeBase && (
|
||||
<BaseTagsModal
|
||||
open={isTagsModalOpen}
|
||||
onOpenChange={setIsTagsModalOpen}
|
||||
knowledgeBaseId={activeKnowledgeBase.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CreateBaseModal open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen} />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,29 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import { Calendar } from 'lucide-react'
|
||||
import { ScheduleList } from '@/app/workspace/[workspaceId]/schedules/components'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Calendar, MoreHorizontal } from '@/components/emcn/icons'
|
||||
import { formatAbsoluteDate, formatRelativeTime } from '@/lib/core/utils/formatting'
|
||||
import { parseCronToHumanReadable } from '@/lib/workflows/schedules/utils'
|
||||
import type { ResourceColumn, ResourceRow } from '@/app/workspace/[workspaceId]/components'
|
||||
import { Resource } from '@/app/workspace/[workspaceId]/components'
|
||||
import type { WorkspaceScheduleData } from '@/hooks/queries/schedules'
|
||||
import { useWorkspaceSchedules } from '@/hooks/queries/schedules'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
|
||||
function getHumanReadable(s: WorkspaceScheduleData) {
|
||||
if (!s.cronExpression && s.nextRunAt) return `Once at ${formatAbsoluteDate(s.nextRunAt)}`
|
||||
if (s.cronExpression) return parseCronToHumanReadable(s.cronExpression, s.timezone)
|
||||
return 'Unknown schedule'
|
||||
}
|
||||
|
||||
const COLUMNS: ResourceColumn[] = [
|
||||
{ id: 'name', header: 'Name', width: 'w-[25%]' },
|
||||
{ id: 'type', header: 'Type', width: 'w-[13%]' },
|
||||
{ id: 'schedule', header: 'Schedule', width: 'w-[24%]' },
|
||||
{ id: 'status', header: 'Status', width: 'w-[10%]' },
|
||||
{ id: 'nextRun', header: 'Next Run', width: 'w-[18%]' },
|
||||
{ id: 'actions', header: 'Actions', width: 'w-[10%]' },
|
||||
]
|
||||
|
||||
export function Schedules() {
|
||||
return (
|
||||
<div className='flex h-full flex-1 flex-col'>
|
||||
<div className='flex flex-1 overflow-hidden'>
|
||||
<div className='flex flex-1 flex-col overflow-auto bg-white px-[24px] pt-[28px] pb-[24px] dark:bg-[var(--bg)]'>
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className='flex items-start gap-[12px]'>
|
||||
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#F59E0B] bg-[#FFFBEB] dark:border-[#B45309] dark:bg-[#451A03]'>
|
||||
<Calendar className='h-[14px] w-[14px] text-[#F59E0B] dark:text-[#FBBF24]' />
|
||||
</div>
|
||||
<h1 className='font-medium text-[18px]'>Schedules</h1>
|
||||
</div>
|
||||
<p className='mt-[10px] text-[14px] text-[var(--text-tertiary)]'>
|
||||
View all scheduled workflows and jobs in your workspace.
|
||||
</p>
|
||||
</div>
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
<ScheduleList />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
const { data: allItems = [], isLoading, error } = useWorkspaceSchedules(workspaceId)
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||
|
||||
const visibleItems = useMemo(
|
||||
() => allItems.filter((item) => item.status !== 'completed'),
|
||||
[allItems]
|
||||
)
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!debouncedSearchQuery) return visibleItems
|
||||
const q = debouncedSearchQuery.toLowerCase()
|
||||
return visibleItems.filter((item) => {
|
||||
const name =
|
||||
item.sourceType === 'job'
|
||||
? item.jobTitle || item.sourceTaskName || ''
|
||||
: item.workflowName || ''
|
||||
return name.toLowerCase().includes(q) || getHumanReadable(item).toLowerCase().includes(q)
|
||||
})
|
||||
}, [visibleItems, debouncedSearchQuery])
|
||||
|
||||
const rows: ResourceRow[] = useMemo(
|
||||
() =>
|
||||
filteredItems.map((item) => {
|
||||
const isJob = item.sourceType === 'job'
|
||||
const name = isJob ? item.jobTitle || item.sourceTaskName || '—' : item.workflowName || '—'
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
cells: {
|
||||
name: {
|
||||
icon: <Calendar className='h-[14px] w-[14px]' />,
|
||||
label: name,
|
||||
},
|
||||
type: { label: isJob ? 'Scheduled Task' : 'Workflow' },
|
||||
schedule: { label: getHumanReadable(item) },
|
||||
status: { label: item.status },
|
||||
nextRun: { label: item.nextRunAt ? formatRelativeTime(item.nextRunAt) : '—' },
|
||||
actions: {
|
||||
icon: <MoreHorizontal className='h-[14px] w-[14px]' />,
|
||||
label: '',
|
||||
},
|
||||
},
|
||||
}
|
||||
}),
|
||||
[filteredItems]
|
||||
)
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(rowId: string) => {
|
||||
const item = filteredItems.find((i) => i.id === rowId)
|
||||
if (item?.workflowId) {
|
||||
router.push(`/workspace/${workspaceId}/w/${item.workflowId}`)
|
||||
}
|
||||
},
|
||||
[filteredItems, router, workspaceId]
|
||||
)
|
||||
|
||||
const emptyState = useMemo(() => {
|
||||
if (debouncedSearchQuery) {
|
||||
return { title: 'No schedules found', description: 'Try a different search term' }
|
||||
}
|
||||
return {
|
||||
title: 'No schedules yet',
|
||||
description: 'Scheduled workflows and tasks will appear here',
|
||||
}
|
||||
}, [debouncedSearchQuery])
|
||||
|
||||
return (
|
||||
<Resource
|
||||
icon={Calendar}
|
||||
title='Schedules'
|
||||
create={{
|
||||
label: 'Create',
|
||||
onClick: () => {},
|
||||
}}
|
||||
search={{
|
||||
value: searchQuery,
|
||||
onChange: setSearchQuery,
|
||||
placeholder: 'Search schedules...',
|
||||
}}
|
||||
onSort={() => {}}
|
||||
onFilter={() => {}}
|
||||
columns={COLUMNS}
|
||||
rows={rows}
|
||||
onRowClick={handleRowClick}
|
||||
isLoading={isLoading}
|
||||
error={
|
||||
error
|
||||
? {
|
||||
title: 'Error loading schedules',
|
||||
description: error instanceof Error ? error.message : 'An error occurred',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
emptyState={emptyState}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,10 +41,6 @@ export function Tables() {
|
||||
const [isSchemaModalOpen, setIsSchemaModalOpen] = useState(false)
|
||||
const [activeTable, setActiveTable] = useState<TableDefinition | null>(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [sortField, setSortField] = useState<string | null>(null)
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
|
||||
const [filterField, setFilterField] = useState<string | null>(null)
|
||||
const [filterValue, setFilterValue] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
isOpen: isListContextMenuOpen,
|
||||
@@ -96,15 +92,9 @@ export function Tables() {
|
||||
[filteredTables]
|
||||
)
|
||||
|
||||
const handleSort = useCallback(() => {
|
||||
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'))
|
||||
setSortField((prev) => prev ?? 'name')
|
||||
}, [])
|
||||
const handleSort = useCallback(() => {}, [])
|
||||
|
||||
const handleFilter = useCallback(() => {
|
||||
setFilterField((prev) => (prev ? null : 'name'))
|
||||
setFilterValue(null)
|
||||
}, [])
|
||||
const handleFilter = useCallback(() => {}, [])
|
||||
|
||||
const handleContentContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Command } from 'cmdk'
|
||||
import { Clock, Database, Files, HelpCircle, Layout, Settings } from 'lucide-react'
|
||||
import { Database, Files, HelpCircle, Settings } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Blimp, Library } from '@/components/emcn'
|
||||
import { Table } from '@/components/emcn/icons'
|
||||
import { Calendar, Table } from '@/components/emcn/icons'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
|
||||
@@ -107,27 +107,6 @@ export function SearchModal({
|
||||
const pages = useMemo(
|
||||
(): PageItem[] =>
|
||||
[
|
||||
{
|
||||
id: 'logs',
|
||||
name: 'Logs',
|
||||
icon: Library,
|
||||
href: `/workspace/${workspaceId}/logs`,
|
||||
shortcut: '⌘⇧L',
|
||||
},
|
||||
{
|
||||
id: 'templates',
|
||||
name: 'Templates',
|
||||
icon: Layout,
|
||||
href: `/workspace/${workspaceId}/templates`,
|
||||
hidden: permissionConfig.hideTemplates,
|
||||
},
|
||||
{
|
||||
id: 'knowledge-base',
|
||||
name: 'Knowledge Base',
|
||||
icon: Database,
|
||||
href: `/workspace/${workspaceId}/knowledge`,
|
||||
hidden: permissionConfig.hideKnowledgeBaseTab,
|
||||
},
|
||||
{
|
||||
id: 'tables',
|
||||
name: 'Tables',
|
||||
@@ -142,12 +121,26 @@ export function SearchModal({
|
||||
href: `/workspace/${workspaceId}/files`,
|
||||
hidden: permissionConfig.hideFilesTab,
|
||||
},
|
||||
{
|
||||
id: 'knowledge-base',
|
||||
name: 'Knowledge Base',
|
||||
icon: Database,
|
||||
href: `/workspace/${workspaceId}/knowledge`,
|
||||
hidden: permissionConfig.hideKnowledgeBaseTab,
|
||||
},
|
||||
{
|
||||
id: 'schedules',
|
||||
name: 'Schedules',
|
||||
icon: Clock,
|
||||
icon: Calendar,
|
||||
href: `/workspace/${workspaceId}/schedules`,
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
name: 'Logs',
|
||||
icon: Library,
|
||||
href: `/workspace/${workspaceId}/logs`,
|
||||
shortcut: '⌘⇧L',
|
||||
},
|
||||
{
|
||||
id: 'help',
|
||||
name: 'Help',
|
||||
@@ -165,8 +158,8 @@ export function SearchModal({
|
||||
workspaceId,
|
||||
openHelpModal,
|
||||
navigateToSettings,
|
||||
permissionConfig.hideTemplates,
|
||||
permissionConfig.hideKnowledgeBaseTab,
|
||||
permissionConfig.hideTablesTab,
|
||||
permissionConfig.hideFilesTab,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -322,7 +322,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
},
|
||||
{
|
||||
id: 'schedules',
|
||||
label: 'Scheduled Tasks',
|
||||
label: 'Schedules',
|
||||
icon: Calendar,
|
||||
href: `/workspace/${workspaceId}/schedules`,
|
||||
},
|
||||
@@ -764,8 +764,26 @@ export const Sidebar = memo(function Sidebar() {
|
||||
>
|
||||
{/* Tasks */}
|
||||
<div className='flex flex-shrink-0 flex-col'>
|
||||
<div className='px-[16px]'>
|
||||
<div className='font-base text-[var(--text-icon)] text-small'>All tasks</div>
|
||||
<div className='flex flex-shrink-0 flex-col space-y-[4px] px-[16px]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='font-base text-[var(--text-icon)] text-small'>All tasks</div>
|
||||
<div className='flex items-center justify-center gap-[8px]'>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='h-[18px] w-[18px] rounded-[4px] p-0 hover:bg-[var(--surface-active)]'
|
||||
onClick={() => router.push(`/workspace/${workspaceId}/home`)}
|
||||
>
|
||||
<Plus className='h-[16px] w-[16px]' />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>New task</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-[6px] flex flex-col gap-[2px] px-[8px]'>
|
||||
{tasksLoading ? (
|
||||
@@ -870,7 +888,7 @@ export const Sidebar = memo(function Sidebar() {
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>
|
||||
<p>{isCreatingWorkflow ? 'Creating workflow...' : 'Create workflow'}</p>
|
||||
<p>{isCreatingWorkflow ? 'Creating workflow...' : 'New workflow'}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ const Switch = React.forwardRef<
|
||||
<SwitchPrimitives.Root
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-[var(--border-1)] transition-colors focus-visible:outline-none data-[disabled]:cursor-not-allowed data-[state=checked]:bg-[var(--brand-tertiary-2)] data-[disabled]:opacity-50',
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full bg-[var(--border-1)] transition-colors focus-visible:outline-none data-[disabled]:cursor-not-allowed data-[state=checked]:bg-[var(--c-2A2A2A)] data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -578,9 +578,7 @@ export function useUndoRedo() {
|
||||
const { edgeSnapshots } = batchAddInverse.data
|
||||
|
||||
const existingEdgeIds = new Set(useWorkflowStore.getState().edges.map((edge) => edge.id))
|
||||
const edgesToAdd = edgeSnapshots.filter(
|
||||
(e) => !existingEdgeIds.has(e.id)
|
||||
)
|
||||
const edgesToAdd = edgeSnapshots.filter((e) => !existingEdgeIds.has(e.id))
|
||||
|
||||
if (edgesToAdd.length > 0) {
|
||||
addToQueue({
|
||||
@@ -633,10 +631,10 @@ export function useUndoRedo() {
|
||||
|
||||
if (useWorkflowStore.getState().blocks[blockId]) {
|
||||
if (newParentId && affectedEdges && affectedEdges.length > 0) {
|
||||
const existingEdgeIds = new Set(useWorkflowStore.getState().edges.map((edge) => edge.id))
|
||||
const edgesToAdd = affectedEdges.filter(
|
||||
(e) => !existingEdgeIds.has(e.id)
|
||||
const existingEdgeIds = new Set(
|
||||
useWorkflowStore.getState().edges.map((edge) => edge.id)
|
||||
)
|
||||
const edgesToAdd = affectedEdges.filter((e) => !existingEdgeIds.has(e.id))
|
||||
if (edgesToAdd.length > 0) {
|
||||
addToQueue({
|
||||
id: crypto.randomUUID(),
|
||||
@@ -698,7 +696,9 @@ export function useUndoRedo() {
|
||||
|
||||
// If we're removing FROM a subflow (undo of add to subflow), remove edges after
|
||||
if (!newParentId && affectedEdges && affectedEdges.length > 0) {
|
||||
const existingEdgeIds = new Set(useWorkflowStore.getState().edges.map((edge) => edge.id))
|
||||
const existingEdgeIds = new Set(
|
||||
useWorkflowStore.getState().edges.map((edge) => edge.id)
|
||||
)
|
||||
const edgeIdsToRemove = affectedEdges
|
||||
.filter((edge) => existingEdgeIds.has(edge.id))
|
||||
.map((edge) => edge.id)
|
||||
@@ -743,9 +743,7 @@ export function useUndoRedo() {
|
||||
|
||||
// Moving OUT of subflow (undoing insert) → restore edges first
|
||||
if (!newParentId && affectedEdges && affectedEdges.length > 0) {
|
||||
const edgesToAdd = affectedEdges.filter(
|
||||
(e) => !existingEdgeIds.has(e.id)
|
||||
)
|
||||
const edgesToAdd = affectedEdges.filter((e) => !existingEdgeIds.has(e.id))
|
||||
allEdgesToAdd.push(...edgesToAdd)
|
||||
}
|
||||
|
||||
@@ -1198,9 +1196,7 @@ export function useUndoRedo() {
|
||||
const { edgeSnapshots } = batchAddOp.data
|
||||
|
||||
const existingEdgeIds = new Set(useWorkflowStore.getState().edges.map((edge) => edge.id))
|
||||
const edgesToAdd = edgeSnapshots.filter(
|
||||
(e) => !existingEdgeIds.has(e.id)
|
||||
)
|
||||
const edgesToAdd = edgeSnapshots.filter((e) => !existingEdgeIds.has(e.id))
|
||||
|
||||
if (edgesToAdd.length > 0) {
|
||||
addToQueue({
|
||||
@@ -1256,7 +1252,9 @@ export function useUndoRedo() {
|
||||
if (useWorkflowStore.getState().blocks[blockId]) {
|
||||
// If we're removing FROM a subflow, remove edges first
|
||||
if (!newParentId && affectedEdges && affectedEdges.length > 0) {
|
||||
const existingEdgeIds = new Set(useWorkflowStore.getState().edges.map((edge) => edge.id))
|
||||
const existingEdgeIds = new Set(
|
||||
useWorkflowStore.getState().edges.map((edge) => edge.id)
|
||||
)
|
||||
const edgeIdsToRemove = affectedEdges
|
||||
.filter((edge) => existingEdgeIds.has(edge.id))
|
||||
.map((edge) => edge.id)
|
||||
@@ -1324,10 +1322,10 @@ export function useUndoRedo() {
|
||||
|
||||
// If we're adding TO a subflow, restore edges after
|
||||
if (newParentId && affectedEdges && affectedEdges.length > 0) {
|
||||
const existingEdgeIds = new Set(useWorkflowStore.getState().edges.map((edge) => edge.id))
|
||||
const edgesToAdd = affectedEdges.filter(
|
||||
(e) => !existingEdgeIds.has(e.id)
|
||||
const existingEdgeIds = new Set(
|
||||
useWorkflowStore.getState().edges.map((edge) => edge.id)
|
||||
)
|
||||
const edgesToAdd = affectedEdges.filter((e) => !existingEdgeIds.has(e.id))
|
||||
if (edgesToAdd.length > 0) {
|
||||
addToQueue({
|
||||
id: crypto.randomUUID(),
|
||||
@@ -1375,9 +1373,7 @@ export function useUndoRedo() {
|
||||
|
||||
// Moving OUT of subflow (redoing removal) → restore edges after
|
||||
if (!newParentId && affectedEdges && affectedEdges.length > 0) {
|
||||
const edgesToAdd = affectedEdges.filter(
|
||||
(e) => !existingEdgeIds.has(e.id)
|
||||
)
|
||||
const edgesToAdd = affectedEdges.filter((e) => !existingEdgeIds.has(e.id))
|
||||
allEdgesToAdd.push(...edgesToAdd)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user