reactquery best practices, UI alignment in restore

This commit is contained in:
waleed
2026-03-13 19:33:10 -07:00
parent 709f91fd29
commit ed7ac935e4
4 changed files with 121 additions and 75 deletions

View File

@@ -0,0 +1,17 @@
import { Skeleton } from '@/components/emcn'
/**
* Skeleton component for recently deleted list items.
*/
export function DeletedItemSkeleton() {
return (
<div className='flex items-center gap-[12px] px-[8px] py-[8px]'>
<Skeleton className='h-[14px] w-[14px] shrink-0 rounded-[3px]' />
<div className='flex min-w-0 flex-1 flex-col gap-[2px]'>
<Skeleton className='h-[14px] w-[120px]' />
<Skeleton className='h-[12px] w-[180px]' />
</div>
<Skeleton className='h-[30px] w-[64px] shrink-0 rounded-[6px]' />
</div>
)
}

View File

@@ -1,20 +1,14 @@
'use client'
import { useMemo, useState } from 'react'
import { Loader2, Search } from 'lucide-react'
import { Search } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import {
Button,
Check,
SModalTabs,
SModalTabsList,
SModalTabsTrigger,
toast,
} from '@/components/emcn'
import { Button, SModalTabs, SModalTabsList, SModalTabsTrigger } from '@/components/emcn'
import { Input } from '@/components/ui'
import { formatDate } from '@/lib/core/utils/formatting'
import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types'
import { DeletedItemSkeleton } from '@/app/workspace/[workspaceId]/settings/components/recently-deleted/deleted-item-skeleton'
import { useKnowledgeBasesQuery, useRestoreKnowledgeBase } from '@/hooks/queries/kb/knowledge'
import { useRestoreTable, useTablesList } from '@/hooks/queries/tables'
import { useRestoreWorkflow, useWorkflows } from '@/hooks/queries/workflows'
@@ -107,6 +101,7 @@ export function RecentlyDeleted() {
const [activeTab, setActiveTab] = useState<ResourceType>('all')
const [searchTerm, setSearchTerm] = useState('')
const [restoringIds, setRestoringIds] = useState<Set<string>>(new Set())
const [restoredItems, setRestoredItems] = useState<Map<string, DeletedResource>>(new Map())
const workflowsQuery = useWorkflows(workspaceId, { syncRegistry: false, scope: 'archived' })
const tablesQuery = useTablesList(workspaceId, 'archived')
@@ -124,6 +119,9 @@ export function RecentlyDeleted() {
knowledgeQuery.isLoading ||
filesQuery.isLoading
const error =
workflowsQuery.error || tablesQuery.error || knowledgeQuery.error || filesQuery.error
const resources = useMemo<DeletedResource[]>(() => {
const items: DeletedResource[] = []
@@ -168,9 +166,24 @@ export function RecentlyDeleted() {
})
}
// Merge back restored items that are no longer in the query data
const itemIds = new Set(items.map((i) => i.id))
for (const [id, resource] of restoredItems) {
if (!itemIds.has(id)) {
items.push(resource)
}
}
items.sort((a, b) => b.deletedAt.getTime() - a.deletedAt.getTime())
return items
}, [workflowsQuery.data, tablesQuery.data, knowledgeQuery.data, filesQuery.data, workspaceId])
}, [
workflowsQuery.data,
tablesQuery.data,
knowledgeQuery.data,
filesQuery.data,
workspaceId,
restoredItems,
])
const filtered = useMemo(() => {
let items = activeTab === 'all' ? resources : resources.filter((r) => r.type === activeTab)
@@ -195,42 +208,30 @@ export function RecentlyDeleted() {
}
const onSuccess = () => {
const href = getResourceHref(resource.workspaceId, resource.type, resource.id)
toast.success(`${resource.name} restored`, {
icon: <Check className='h-[12px] w-[12px]' />,
action: { label: 'View', onClick: () => router.push(href) },
})
}
const onError = () => {
toast.error(`Failed to restore ${resource.name}`)
setRestoredItems((prev) => new Map(prev).set(resource.id, resource))
}
switch (resource.type) {
case 'workflow':
restoreWorkflow.mutate(resource.id, { onSettled, onSuccess, onError })
restoreWorkflow.mutate(resource.id, { onSettled, onSuccess })
break
case 'table':
restoreTable.mutate(resource.id, { onSettled, onSuccess, onError })
restoreTable.mutate(resource.id, { onSettled, onSuccess })
break
case 'knowledge':
restoreKnowledgeBase.mutate(resource.id, { onSettled, onSuccess, onError })
restoreKnowledgeBase.mutate(resource.id, { onSettled, onSuccess })
break
case 'file':
restoreWorkspaceFile.mutate(
{ workspaceId: resource.workspaceId, fileId: resource.id },
{ onSettled, onSuccess, onError }
{ onSettled, onSuccess }
)
break
}
}
return (
<div className='flex flex-col gap-[16px]'>
<p className='text-[13px] text-[var(--text-secondary)]'>
Items you delete are kept here for 30 days before being permanently removed.
</p>
<div className='flex h-full flex-col gap-[18px]'>
<div className='flex items-center gap-[8px] rounded-[8px] border border-[var(--border)] bg-transparent px-[8px] py-[5px] transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover:border-[var(--border-1)] dark:hover:bg-[var(--surface-5)]'>
<Search
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
@@ -246,7 +247,7 @@ export function RecentlyDeleted() {
</div>
<SModalTabs value={activeTab} onValueChange={(v) => setActiveTab(v as ResourceType)}>
<SModalTabsList activeValue={activeTab} className='border-[var(--border)] border-b'>
<SModalTabsList activeValue={activeTab} className='border-b border-[var(--border)]'>
{TABS.map((tab) => (
<SModalTabsTrigger key={tab.id} value={tab.id}>
{tab.label}
@@ -255,55 +256,81 @@ export function RecentlyDeleted() {
</SModalTabsList>
</SModalTabs>
{isLoading ? (
<div className='flex items-center justify-center py-[48px]'>
<Loader2 className='h-5 w-5 animate-spin text-[var(--text-tertiary)]' />
</div>
) : filtered.length === 0 ? (
<div className='flex flex-col items-center justify-center py-[48px] text-[var(--text-tertiary)]'>
<p className='text-[13px]'>
<div className='min-h-0 flex-1 overflow-y-auto'>
{error ? (
<div className='flex h-full flex-col items-center justify-center gap-[8px]'>
<p className='text-[11px] leading-tight text-[#DC2626] dark:text-[#F87171]'>
{error instanceof Error ? error.message : 'Failed to load deleted items'}
</p>
</div>
) : isLoading ? (
<div className='flex flex-col gap-[8px]'>
<DeletedItemSkeleton />
<DeletedItemSkeleton />
<DeletedItemSkeleton />
</div>
) : filtered.length === 0 ? (
<div className='flex h-full items-center justify-center text-[14px] text-[var(--text-muted)]'>
{showNoResults
? `No items found matching \u201c${searchTerm}\u201d`
: 'No deleted items'}
</p>
</div>
) : (
<div className='flex flex-col'>
{filtered.map((resource) => {
const isRestoring = restoringIds.has(resource.id)
</div>
) : (
<div className='flex flex-col gap-[8px]'>
{filtered.map((resource) => {
const isRestoring = restoringIds.has(resource.id)
const isRestored = restoredItems.has(resource.id)
return (
<div
key={resource.id}
className='flex items-center gap-[12px] rounded-[6px] px-[8px] py-[8px] hover:bg-[var(--bg-hover)]'
>
<ResourceIcon resource={resource} />
<div className='flex min-w-0 flex-1 flex-col'>
<span className='truncate font-medium text-[13px] text-[var(--text-primary)]'>
{resource.name}
</span>
<span className='text-[12px] text-[var(--text-tertiary)]'>
{TYPE_LABEL[resource.type]}
{' \u00b7 '}
Deleted {formatDate(resource.deletedAt)}
</span>
</div>
<Button
variant='default'
size='sm'
disabled={isRestoring}
onClick={() => handleRestore(resource)}
className='shrink-0'
return (
<div
key={resource.id}
className='flex items-center gap-[12px] rounded-[6px] px-[8px] py-[8px] hover:bg-[var(--bg-hover)]'
>
{isRestoring ? <Loader2 className='h-3.5 w-3.5 animate-spin' /> : 'Restore'}
</Button>
</div>
)
})}
</div>
)}
<ResourceIcon resource={resource} />
<div className='flex flex-col min-w-0 flex-1'>
<span className='text-[13px] font-medium text-[var(--text-primary)] truncate'>
{resource.name}
</span>
<span className='text-[12px] text-[var(--text-tertiary)]'>
{TYPE_LABEL[resource.type]}
{' \u00b7 '}
Deleted {formatDate(resource.deletedAt)}
</span>
</div>
{isRestored ? (
<div className='flex items-center gap-[8px] shrink-0'>
<span className='text-[13px] text-[var(--text-tertiary)]'>Restored</span>
<Button
variant='default'
size='sm'
onClick={() =>
router.push(
getResourceHref(resource.workspaceId, resource.type, resource.id)
)
}
>
View
</Button>
</div>
) : (
<Button
variant='default'
size='sm'
disabled={isRestoring}
onClick={() => handleRestore(resource)}
className='shrink-0'
>
{isRestoring ? 'Restoring...' : 'Restore'}
</Button>
)}
</div>
)
})}
</div>
)}
</div>
</div>
)
}

View File

@@ -14,8 +14,9 @@ type KnowledgeQueryScope = 'active' | 'archived' | 'all'
export const knowledgeKeys = {
all: ['knowledge'] as const,
lists: () => [...knowledgeKeys.all, 'list'] as const,
list: (workspaceId?: string, scope: KnowledgeQueryScope = 'active') =>
[...knowledgeKeys.all, 'list', workspaceId ?? 'all', scope] as const,
[...knowledgeKeys.lists(), workspaceId ?? 'all', scope] as const,
detail: (knowledgeBaseId?: string) =>
[...knowledgeKeys.all, 'detail', knowledgeBaseId ?? ''] as const,
tagDefinitions: (knowledgeBaseId: string) =>
@@ -1232,7 +1233,7 @@ export function useRestoreKnowledgeBase() {
return res.json()
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: knowledgeKeys.all })
queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() })
},
})
}

View File

@@ -171,6 +171,7 @@ export function useTablesList(workspaceId?: string, scope: TableQueryScope = 'ac
},
enabled: Boolean(workspaceId),
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}