Skip to content

Commit c176e50

Browse files
committed
react query best practices: targeted invalidation, optimistic updates, key factory hierarchy
- Add workspaceLists(workspaceId) intermediate key level to both workspaceFilesKeys and workspaceFileFolderKeys so invalidation targets only the affected workspace instead of all workspaces - Replace all lists() invalidation calls with workspaceLists(workspaceId) across every mutation (upload, rename, delete, restore, update content, folder mutations) - Add optimistic updates to useRenameWorkspaceFile and useUpdateWorkspaceFileFolder with onMutate snapshot, onError rollback, onSettled reconciliation - Move storage key into the content() factory as optional param so query keys are always built through the factory (useWorkspaceFileContent, useWorkspaceFileBinary) - Fix AnimatePresence wrapping in FilesActionBar so exit animation fires on deselect - Fix ResourceColGroup to use percentage weights instead of pixel widths to prevent horizontal scroll on narrow viewports
1 parent 5f04bd4 commit c176e50

6 files changed

Lines changed: 199 additions & 94 deletions

File tree

apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export interface SelectableConfig {
4747

4848
export interface RowDragDropConfig {
4949
activeDropTargetId?: string | null
50+
draggedRowIds?: Set<string>
51+
isAnyDragActive?: boolean
5052
isRowDraggable?: (rowId: string) => boolean
5153
isRowDropTarget?: (rowId: string) => boolean
5254
onDragStart?: (e: DragEvent<HTMLTableRowElement>, rowId: string) => void
@@ -490,6 +492,8 @@ const DataRow = memo(function DataRow({
490492
const isDraggable = rowDragDrop?.isRowDraggable?.(row.id) ?? false
491493
const isDropTarget = rowDragDrop?.isRowDropTarget?.(row.id) ?? false
492494
const isActiveDropTarget = rowDragDrop?.activeDropTargetId === row.id
495+
const isDragging = rowDragDrop?.draggedRowIds?.has(row.id) ?? false
496+
const isAnyDragActive = rowDragDrop?.isAnyDragActive ?? false
493497

494498
const handleClick = useCallback(() => {
495499
onRowClick?.(row.id)
@@ -553,12 +557,14 @@ const DataRow = memo(function DataRow({
553557
data-resource-row
554558
data-row-id={row.id}
555559
className={cn(
556-
'transition-colors hover-hover:bg-[var(--surface-3)]',
560+
'transition-colors',
561+
!isAnyDragActive && 'hover-hover:bg-[var(--surface-3)]',
557562
onRowClick && 'cursor-pointer',
558563
isDraggable && 'cursor-grab active:cursor-grabbing',
559564
isDropTarget && 'data-[drop-target=true]:outline-offset-[-1px]',
560565
(selectedRowId === row.id || isSelected) && 'bg-[var(--surface-3)]',
561-
isActiveDropTarget && 'bg-[var(--surface-4)] outline outline-1 outline-[var(--accent)]'
566+
isActiveDropTarget && 'bg-[var(--surface-4)] outline outline-1 outline-[var(--accent)]',
567+
(isDragging || (isAnyDragActive && isSelected && !isActiveDropTarget)) && 'opacity-50'
562568
)}
563569
data-drop-target={isDropTarget || undefined}
564570
draggable={isDraggable}
@@ -629,22 +635,22 @@ interface ResourceColGroupProps {
629635
hasCheckbox?: boolean
630636
}
631637

638+
const CHECKBOX_WEIGHT = 0.4
639+
632640
const ResourceColGroup = memo(function ResourceColGroup({
633641
columns,
634642
hasCheckbox,
635643
}: ResourceColGroupProps) {
644+
const weights = columns.map(
645+
(col, colIdx) => (colIdx === 0 ? 2.5 : 1.0) * (col.widthMultiplier ?? 1)
646+
)
647+
const total = (hasCheckbox ? CHECKBOX_WEIGHT : 0) + weights.reduce((s, w) => s + w, 0)
648+
636649
return (
637650
<colgroup>
638-
{hasCheckbox && <col className='w-[52px]' />}
651+
{hasCheckbox && <col style={{ width: `${(CHECKBOX_WEIGHT / total) * 100}%` }} />}
639652
{columns.map((col, colIdx) => (
640-
<col
641-
key={col.id}
642-
style={
643-
colIdx === 0
644-
? { width: 400 * (col.widthMultiplier ?? 1) }
645-
: { width: 160 * (col.widthMultiplier ?? 1) }
646-
}
647-
/>
653+
<col key={col.id} style={{ width: `${(weights[colIdx] / total) * 100}%` }} />
648654
))}
649655
</colgroup>
650656
)
Lines changed: 65 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { domAnimation, LazyMotion, m } from 'framer-motion'
3+
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
44
import { Button, Download, Tooltip, Trash2 } from '@/components/emcn'
55
import { Folder } from '@/components/emcn/icons'
66
import { cn } from '@/lib/core/utils/cn'
@@ -22,70 +22,72 @@ export function FilesActionBar({
2222
isLoading = false,
2323
className,
2424
}: FilesActionBarProps) {
25-
if (selectedCount === 0) return null
26-
2725
return (
2826
<LazyMotion features={domAnimation}>
29-
<m.div
30-
initial={{ opacity: 0, y: 10 }}
31-
animate={{ opacity: 1, y: 0 }}
32-
exit={{ opacity: 0, y: 10 }}
33-
transition={{ duration: 0.2 }}
34-
className={cn('-translate-x-1/2 fixed bottom-6 left-1/2 z-50 transform', className)}
35-
>
36-
<div className='flex items-center gap-2 rounded-[10px] border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1.5'>
37-
<span className='px-1 text-[var(--text-secondary)] text-small'>
38-
{selectedCount} selected
39-
</span>
40-
<div className='flex items-center gap-[5px]'>
41-
{onDownload && (
42-
<Tooltip.Root>
43-
<Tooltip.Trigger asChild>
44-
<Button
45-
variant='ghost'
46-
onClick={onDownload}
47-
disabled={isLoading}
48-
className='hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg bg-[var(--surface-5)] p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]'
49-
>
50-
<Download className='size-[12px]' />
51-
</Button>
52-
</Tooltip.Trigger>
53-
<Tooltip.Content side='top'>Download</Tooltip.Content>
54-
</Tooltip.Root>
55-
)}
56-
{onMove && (
57-
<Tooltip.Root>
58-
<Tooltip.Trigger asChild>
59-
<Button
60-
variant='ghost'
61-
onClick={onMove}
62-
disabled={isLoading}
63-
className='hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg bg-[var(--surface-5)] p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]'
64-
>
65-
<Folder className='size-[12px]' />
66-
</Button>
67-
</Tooltip.Trigger>
68-
<Tooltip.Content side='top'>Move</Tooltip.Content>
69-
</Tooltip.Root>
70-
)}
71-
{onDelete && (
72-
<Tooltip.Root>
73-
<Tooltip.Trigger asChild>
74-
<Button
75-
variant='ghost'
76-
onClick={onDelete}
77-
disabled={isLoading}
78-
className='hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg bg-[var(--surface-5)] p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]'
79-
>
80-
<Trash2 className='size-[12px]' />
81-
</Button>
82-
</Tooltip.Trigger>
83-
<Tooltip.Content side='top'>Delete</Tooltip.Content>
84-
</Tooltip.Root>
85-
)}
86-
</div>
87-
</div>
88-
</m.div>
27+
<AnimatePresence>
28+
{selectedCount > 0 && (
29+
<m.div
30+
initial={{ opacity: 0, y: 10 }}
31+
animate={{ opacity: 1, y: 0 }}
32+
exit={{ opacity: 0, y: 10 }}
33+
transition={{ duration: 0.2 }}
34+
className={cn('-translate-x-1/2 fixed bottom-6 left-1/2 z-50 transform', className)}
35+
>
36+
<div className='flex items-center gap-2 rounded-[10px] border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1.5'>
37+
<span className='px-1 text-[var(--text-secondary)] text-small'>
38+
{selectedCount} selected
39+
</span>
40+
<div className='flex items-center gap-[5px]'>
41+
{onDownload && (
42+
<Tooltip.Root>
43+
<Tooltip.Trigger asChild>
44+
<Button
45+
variant='ghost'
46+
onClick={onDownload}
47+
disabled={isLoading}
48+
className='hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg bg-[var(--surface-5)] p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]'
49+
>
50+
<Download className='size-[12px]' />
51+
</Button>
52+
</Tooltip.Trigger>
53+
<Tooltip.Content side='top'>Download</Tooltip.Content>
54+
</Tooltip.Root>
55+
)}
56+
{onMove && (
57+
<Tooltip.Root>
58+
<Tooltip.Trigger asChild>
59+
<Button
60+
variant='ghost'
61+
onClick={onMove}
62+
disabled={isLoading}
63+
className='hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg bg-[var(--surface-5)] p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]'
64+
>
65+
<Folder className='size-[12px]' />
66+
</Button>
67+
</Tooltip.Trigger>
68+
<Tooltip.Content side='top'>Move</Tooltip.Content>
69+
</Tooltip.Root>
70+
)}
71+
{onDelete && (
72+
<Tooltip.Root>
73+
<Tooltip.Trigger asChild>
74+
<Button
75+
variant='ghost'
76+
onClick={onDelete}
77+
disabled={isLoading}
78+
className='hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg bg-[var(--surface-5)] p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]'
79+
>
80+
<Trash2 className='size-[12px]' />
81+
</Button>
82+
</Tooltip.Trigger>
83+
<Tooltip.Content side='top'>Delete</Tooltip.Content>
84+
</Tooltip.Root>
85+
)}
86+
</div>
87+
</div>
88+
</m.div>
89+
)}
90+
</AnimatePresence>
8991
</LazyMotion>
9092
)
9193
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { FilesActionBar } from './action-bar'

apps/sim/app/workspace/[workspaceId]/files/files.tsx

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ import {
5858
ResourceHeader,
5959
timeCell,
6060
} from '@/app/workspace/[workspaceId]/components'
61-
import { FilesActionBar } from '@/app/workspace/[workspaceId]/files/components/action-bar/action-bar'
61+
import { FilesActionBar } from '@/app/workspace/[workspaceId]/files/components/action-bar'
6262
import { DeleteConfirmModal } from '@/app/workspace/[workspaceId]/files/components/delete-confirm-modal'
6363
import { FileRowContextMenu } from '@/app/workspace/[workspaceId]/files/components/file-row-context-menu'
6464
import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
@@ -212,6 +212,8 @@ export function Files() {
212212
const justCreatedFileIdRef = useRef<string | null>(null)
213213
const filesRef = useRef(files)
214214
filesRef.current = files
215+
const foldersRef = useRef(folders)
216+
foldersRef.current = folders
215217

216218
const [uploading, setUploading] = useState(false)
217219
const [uploadProgress, setUploadProgress] = useState({
@@ -236,6 +238,7 @@ export function Files() {
236238
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
237239
const [selectedRowIds, setSelectedRowIds] = useState<Set<string>>(() => new Set())
238240
const [activeDropTargetId, setActiveDropTargetId] = useState<string | null>(null)
241+
const [draggedRowIds, setDraggedRowIds] = useState<Set<string>>(() => new Set())
239242
const [showMoveModal, setShowMoveModal] = useState(false)
240243
const [moveTargetFolderId, setMoveTargetFolderId] = useState<string | null>(null)
241244
const [previewMode, setPreviewMode] = useState<PreviewMode>(() => {
@@ -251,6 +254,7 @@ export function Files() {
251254
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
252255
const contextMenuItemRef = useRef<FileResourceItem | null>(null)
253256
const draggedRowIdsRef = useRef<string[]>([])
257+
const dragGhostRef = useRef<HTMLElement | null>(null)
254258
const [deleteTarget, setDeleteTarget] = useState<{
255259
fileIds: string[]
256260
folderIds: string[]
@@ -647,6 +651,8 @@ export function Files() {
647651
const rowDragDropConfig = useMemo<RowDragDropConfig>(
648652
() => ({
649653
activeDropTargetId,
654+
draggedRowIds,
655+
isAnyDragActive: draggedRowIds.size > 0,
650656
isRowDraggable: (rowId) => canEdit && listRename.editingId !== rowId,
651657
isRowDropTarget: (rowId) => canEdit && parseRowId(rowId).kind === 'folder',
652658
onDragStart: (e: DragEvent<HTMLTableRowElement>, rowId) => {
@@ -660,6 +666,7 @@ export function Files() {
660666
: [rowId]
661667

662668
draggedRowIdsRef.current = sourceRowIds
669+
setDraggedRowIds(new Set(sourceRowIds))
663670
if (!selectedRowIds.has(rowId)) {
664671
setSelectedRowIds(new Set([rowId]))
665672
}
@@ -670,6 +677,26 @@ export function Files() {
670677
JSON.stringify(sourceRowIds)
671678
)
672679
e.dataTransfer.setData('text/plain', sourceRowIds.join(','))
680+
681+
const count = sourceRowIds.length
682+
const firstParsed = parseRowId(sourceRowIds[0])
683+
const firstName =
684+
firstParsed.kind === 'file'
685+
? filesRef.current.find((f) => f.id === firstParsed.id)?.name
686+
: foldersRef.current.find((f) => f.id === firstParsed.id)?.name
687+
const ghostLabel =
688+
count > 1 ? `${firstName ?? 'Items'} +${count - 1} more` : (firstName ?? 'Item')
689+
const ghost = document.createElement('div')
690+
ghost.style.cssText =
691+
'position:fixed;top:-500px;left:0;display:inline-flex;align-items:center;padding:4px 10px;background:var(--surface-active);border:1px solid rgba(255,255,255,0.08);border-radius:8px;font-family:system-ui,-apple-system,sans-serif;font-size:13px;color:var(--text-body);white-space:nowrap;pointer-events:none;box-shadow:0 4px 12px rgba(0,0,0,0.4);z-index:9999'
692+
const text = document.createElement('span')
693+
text.style.cssText = 'max-width:200px;overflow:hidden;text-overflow:ellipsis'
694+
text.textContent = ghostLabel
695+
ghost.appendChild(text)
696+
document.body.appendChild(ghost)
697+
void ghost.offsetHeight
698+
e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2)
699+
dragGhostRef.current = ghost
673700
},
674701
onDragOver: (e: DragEvent<HTMLTableRowElement>, rowId) => {
675702
const sourceRowIds = draggedRowIdsRef.current
@@ -745,14 +772,20 @@ export function Files() {
745772
})
746773
},
747774
onDragEnd: () => {
775+
if (dragGhostRef.current) {
776+
dragGhostRef.current.remove()
777+
dragGhostRef.current = null
778+
}
748779
dragCounterRef.current = 0
749780
draggedRowIdsRef.current = []
781+
setDraggedRowIds(new Set())
750782
setIsDraggingOver(false)
751783
setActiveDropTargetId(null)
752784
},
753785
}),
754786
[
755787
activeDropTargetId,
788+
draggedRowIds,
756789
canEdit,
757790
listRename.editingId,
758791
selectedRowIds,

apps/sim/hooks/queries/workspace-file-folders.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ export type { WorkspaceFileFolderApi }
1616
export const workspaceFileFolderKeys = {
1717
all: ['workspaceFileFolders'] as const,
1818
lists: () => [...workspaceFileFolderKeys.all, 'list'] as const,
19+
workspaceLists: (workspaceId: string) =>
20+
[...workspaceFileFolderKeys.lists(), workspaceId] as const,
1921
list: (workspaceId: string, scope: WorkspaceFileFolderScope = 'active') =>
20-
[...workspaceFileFolderKeys.lists(), workspaceId, scope] as const,
22+
[...workspaceFileFolderKeys.workspaceLists(workspaceId), scope] as const,
2123
}
2224

2325
async function fetchWorkspaceFileFolders(
@@ -37,10 +39,9 @@ function invalidateWorkspaceFileBrowsers(
3739
queryClient: ReturnType<typeof useQueryClient>,
3840
workspaceId: string
3941
) {
40-
queryClient.invalidateQueries({ queryKey: workspaceFileFolderKeys.lists() })
41-
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.lists() })
42+
queryClient.invalidateQueries({ queryKey: workspaceFileFolderKeys.workspaceLists(workspaceId) })
43+
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.workspaceLists(workspaceId) })
4244
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() })
43-
queryClient.invalidateQueries({ queryKey: workspaceFileFolderKeys.list(workspaceId) })
4445
}
4546

4647
export function useWorkspaceFileFolders(
@@ -89,6 +90,29 @@ export function useUpdateWorkspaceFileFolder() {
8990
})
9091
return data.folder
9192
},
93+
onMutate: async ({ workspaceId, folderId, updates }) => {
94+
await queryClient.cancelQueries({
95+
queryKey: workspaceFileFolderKeys.workspaceLists(workspaceId),
96+
})
97+
const previous = queryClient.getQueryData<WorkspaceFileFolderApi[]>(
98+
workspaceFileFolderKeys.list(workspaceId, 'active')
99+
)
100+
if (previous) {
101+
queryClient.setQueryData<WorkspaceFileFolderApi[]>(
102+
workspaceFileFolderKeys.list(workspaceId, 'active'),
103+
previous.map((f) => (f.id === folderId ? { ...f, ...updates } : f))
104+
)
105+
}
106+
return { previous }
107+
},
108+
onError: (_err, variables, context) => {
109+
if (context?.previous) {
110+
queryClient.setQueryData(
111+
workspaceFileFolderKeys.list(variables.workspaceId, 'active'),
112+
context.previous
113+
)
114+
}
115+
},
92116
onSettled: (_data, _error, variables) => {
93117
invalidateWorkspaceFileBrowsers(queryClient, variables.workspaceId)
94118
},

0 commit comments

Comments
 (0)