Compare commits

...

2 Commits

Author SHA1 Message Date
c0f852ad35 fix: directory table optimistic update
fix optimistic update not working for directory table and trash table
2025-10-18 22:58:23 +00:00
efd4eefa49 fix: trash page breadcrumb
Co-authored-by: Ona <no-reply@ona.com>
2025-10-18 20:14:13 +00:00
5 changed files with 145 additions and 41 deletions

View File

@@ -1,15 +1,64 @@
import { type PrimitiveAtom, useAtom } from "jotai" import {
type Atom,
type ExtractAtomArgs,
type ExtractAtomResult,
type ExtractAtomValue,
type PrimitiveAtom,
type SetStateAction,
useAtom,
type WritableAtom,
} from "jotai"
import type * as React from "react"
export function WithAtom<Value>({ type SetAtom<Args extends unknown[], Result> = (...args: Args) => Result
export function WithAtom<Value, Args extends unknown[], Result>(props: {
atom: WritableAtom<Value, Args, Result>
children: (
value: Awaited<Value>,
setAtom: SetAtom<Args, Result>,
) => React.ReactNode
}): React.ReactNode
export function WithAtom<Value>(props: {
atom: PrimitiveAtom<Value>
children: (
value: Awaited<Value>,
setAtom: SetAtom<[SetStateAction<Value>], void>,
) => React.ReactNode
}): React.ReactNode
export function WithAtom<Value>(props: {
atom: Atom<Value>
children: (value: Awaited<Value>, setAtom: never) => React.ReactNode
}): React.ReactNode
export function WithAtom<
AtomType extends WritableAtom<unknown, never[], unknown>,
>(props: {
atom: AtomType
children: (
value: Awaited<ExtractAtomValue<AtomType>>,
setAtom: SetAtom<
ExtractAtomArgs<AtomType>,
ExtractAtomResult<AtomType>
>,
) => React.ReactNode
}): React.ReactNode
export function WithAtom<AtomType extends Atom<unknown>>(props: {
atom: AtomType
children: (
value: Awaited<ExtractAtomValue<AtomType>>,
setAtom: never,
) => React.ReactNode
}): React.ReactNode
export function WithAtom<Value, Args extends unknown[], Result>({
atom, atom,
children, children,
}: { }: {
atom: PrimitiveAtom<Value> atom: Atom<Value> | WritableAtom<Value, Args, Result>
children: ( children: (
value: Value, value: Awaited<Value>,
setValue: (value: Value | ((current: Value) => Value)) => void, setAtom: SetAtom<Args, Result> | never,
) => React.ReactNode ) => React.ReactNode
}) { }) {
const [value, setValue] = useAtom(atom) const [value, setAtom] = useAtom(atom as WritableAtom<Value, Args, Result>)
return children(value, setValue) return children(value, setAtom)
} }

View File

@@ -15,6 +15,7 @@ import {
type ColumnDef, type ColumnDef,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
getFilteredRowModel,
type Row, type Row,
type Table as TableType, type Table as TableType,
useReactTable, useReactTable,
@@ -40,8 +41,10 @@ import { type FileDragInfo, useFileDrop } from "../../files/use-file-drop"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
import { DirectoryPageContext } from "./context" import { DirectoryPageContext } from "./context"
type DirectoryContentTableItemIdFilter = Set<FileSystemItem["doc"]["_id"]>
type DirectoryContentTableProps = { type DirectoryContentTableProps = {
filterFn: (item: FileSystemItem) => boolean hiddenItems: DirectoryContentTableItemIdFilter
directoryUrlFn: (directory: Doc<"directories">) => string directoryUrlFn: (directory: Doc<"directories">) => string
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null> fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null>
onContextMenu: ( onContextMenu: (
@@ -150,7 +153,7 @@ function useTableColumns(
} }
export function DirectoryContentTable({ export function DirectoryContentTable({
filterFn, hiddenItems,
directoryUrlFn, directoryUrlFn,
onContextMenu, onContextMenu,
fileDragInfoAtom, fileDragInfoAtom,
@@ -164,10 +167,18 @@ export function DirectoryContentTable({
data: directoryContent || [], data: directoryContent || [],
columns: useTableColumns(onOpenFile, directoryUrlFn), columns: useTableColumns(onOpenFile, directoryUrlFn),
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
enableRowSelection: true, enableRowSelection: true,
enableGlobalFilter: true, enableGlobalFilter: true,
globalFilterFn: (row, _columnId, _filterValue, _addMeta) => state: {
filterFn(row.original), globalFilter: hiddenItems,
},
globalFilterFn: (
row,
_columnId,
filterValue: DirectoryContentTableItemIdFilter,
_addMeta,
) => !filterValue.has(row.original.doc._id),
getRowId: (row) => row.doc._id, getRowId: (row) => row.doc._id,
}) })

View File

@@ -6,6 +6,7 @@ import type {
import type { DirectoryInfo } from "@fileone/convex/types" import type { DirectoryInfo } from "@fileone/convex/types"
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import type { PrimitiveAtom } from "jotai" import type { PrimitiveAtom } from "jotai"
import { atom } from "jotai"
import { Fragment } from "react" import { Fragment } from "react"
import { import {
Breadcrumb, Breadcrumb,
@@ -24,16 +25,21 @@ import type { FileDragInfo } from "@/files/use-file-drop"
import { useFileDrop } from "@/files/use-file-drop" import { useFileDrop } from "@/files/use-file-drop"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
/**
* This is a placeholder file drag info atom that always stores null and is never mutated.
*/
const nullFileDragInfoAtom = atom<FileDragInfo | null>(null)
export function DirectoryPathBreadcrumb({ export function DirectoryPathBreadcrumb({
directory, directory,
rootLabel, rootLabel,
directoryUrlFn, directoryUrlFn,
fileDragInfoAtom, fileDragInfoAtom = nullFileDragInfoAtom,
}: { }: {
directory: DirectoryInfo directory: DirectoryInfo
rootLabel: string rootLabel: string
directoryUrlFn: (directory: Id<"directories">) => string directoryUrlFn: (directory: Id<"directories">) => string
fileDragInfoAtom: PrimitiveAtom<FileDragInfo | null> fileDragInfoAtom?: PrimitiveAtom<FileDragInfo | null>
}) { }) {
const breadcrumbItems: React.ReactNode[] = [ const breadcrumbItems: React.ReactNode[] = [
<FilePathBreadcrumbItem <FilePathBreadcrumbItem

View File

@@ -2,7 +2,6 @@ import { api } from "@fileone/convex/api"
import type { Doc, Id } from "@fileone/convex/dataModel" import type { Doc, Id } from "@fileone/convex/dataModel"
import { import {
type FileSystemItem, type FileSystemItem,
FileType,
newFileSystemHandle, newFileSystemHandle,
} from "@fileone/convex/filesystem" } from "@fileone/convex/filesystem"
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
@@ -37,6 +36,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { WithAtom } from "@/components/with-atom" import { WithAtom } from "@/components/with-atom"
import { backgroundTaskProgressAtom } from "@/dashboard/state"
import { DirectoryPageContext } from "@/directories/directory-page/context" import { DirectoryPageContext } from "@/directories/directory-page/context"
import { DirectoryContentTable } from "@/directories/directory-page/directory-content-table" import { DirectoryContentTable } from "@/directories/directory-page/directory-content-table"
import { DirectoryPageSkeleton } from "@/directories/directory-page/directory-page-skeleton" import { DirectoryPageSkeleton } from "@/directories/directory-page/directory-page-skeleton"
@@ -83,6 +83,16 @@ const itemBeingRenamedAtom = atom<{
name: string name: string
} | null>(null) } | null>(null)
const tableFilterAtom = atom((get) => {
const optimisticDeletedItems = get(optimisticDeletedItemsAtom)
console.log("optimisticDeletedItems", optimisticDeletedItems)
return (item: FileSystemItem) => {
const test = !optimisticDeletedItems.has(item.doc._id)
console.log("test", test)
return test
}
})
// MARK: page entry // MARK: page entry
function RouteComponent() { function RouteComponent() {
const { directoryId } = Route.useParams() const { directoryId } = Route.useParams()
@@ -161,13 +171,17 @@ function RouteComponent() {
{/* DirectoryContentContextMenu must wrap div instead of DirectoryContentTable, otherwise radix will throw "event.preventDefault is not a function" error, idk why */} {/* DirectoryContentContextMenu must wrap div instead of DirectoryContentTable, otherwise radix will throw "event.preventDefault is not a function" error, idk why */}
<DirectoryContentContextMenu> <DirectoryContentContextMenu>
<div className="w-full"> <div className="w-full">
<DirectoryContentTable <WithAtom atom={optimisticDeletedItemsAtom}>
filterFn={tableFilter} {(optimisticDeletedItems) => (
directoryUrlFn={directoryUrlFn} <DirectoryContentTable
fileDragInfoAtom={fileDragInfoAtom} hiddenItems={optimisticDeletedItems}
onContextMenu={handleContextMenuRequest} directoryUrlFn={directoryUrlFn}
onOpenFile={openFile} fileDragInfoAtom={fileDragInfoAtom}
/> onContextMenu={handleContextMenuRequest}
onOpenFile={openFile}
/>
)}
</WithAtom>
</div> </div>
</DirectoryContentContextMenu> </DirectoryContentContextMenu>
@@ -225,8 +239,9 @@ function RouteComponent() {
} }
// ================================== // ==================================
// MARK: DirectoryContentContextMenu // MARK: ctx menu
// tags: ctxmenu contextmenu directorycontextmenu
function DirectoryContentContextMenu({ function DirectoryContentContextMenu({
children, children,
}: { }: {
@@ -235,17 +250,22 @@ function DirectoryContentContextMenu({
const store = useStore() const store = useStore()
const [target, setTarget] = useAtom(contextMenuTargetItemsAtom) const [target, setTarget] = useAtom(contextMenuTargetItemsAtom)
const setOptimisticDeletedItems = useSetAtom(optimisticDeletedItemsAtom) const setOptimisticDeletedItems = useSetAtom(optimisticDeletedItemsAtom)
const setBackgroundTaskProgress = useSetAtom(backgroundTaskProgressAtom)
const moveToTrashMutation = useContextMutation(api.filesystem.moveToTrash) const moveToTrashMutation = useContextMutation(api.filesystem.moveToTrash)
const { mutate: moveToTrash } = useMutation({ const { mutate: moveToTrash } = useMutation({
mutationFn: moveToTrashMutation, mutationFn: moveToTrashMutation,
onMutate: ({ handles }) => { onMutate: ({ handles }) => {
setBackgroundTaskProgress({
label: "Moving items to trash…",
})
setOptimisticDeletedItems( setOptimisticDeletedItems(
(prev) => (prev) =>
new Set([...prev, ...handles.map((handle) => handle.id)]), new Set([...prev, ...handles.map((handle) => handle.id)]),
) )
}, },
onSuccess: ({ deleted, errors }, { handles }) => { onSuccess: ({ deleted, errors }, { handles }) => {
setBackgroundTaskProgress(null)
setOptimisticDeletedItems((prev) => { setOptimisticDeletedItems((prev) => {
const newSet = new Set(prev) const newSet = new Set(prev)
for (const handle of handles) { for (const handle of handles) {
@@ -263,6 +283,15 @@ function DirectoryContentContextMenu({
) )
} }
}, },
onError: (_err, { handles }) => {
setOptimisticDeletedItems((prev) => {
const newSet = new Set(prev)
for (const handle of handles) {
newSet.delete(handle.id)
}
return newSet
})
},
}) })
const handleDelete = () => { const handleDelete = () => {

View File

@@ -14,7 +14,7 @@ import {
} from "convex/react" } from "convex/react"
import { atom, useAtom, useSetAtom, useStore } from "jotai" import { atom, useAtom, useSetAtom, useStore } from "jotai"
import { ShredderIcon, TrashIcon, UndoIcon } from "lucide-react" import { ShredderIcon, TrashIcon, UndoIcon } from "lucide-react"
import { useCallback, useContext, useEffect } from "react" import { useCallback, useContext } from "react"
import { toast } from "sonner" import { toast } from "sonner"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
@@ -109,6 +109,7 @@ function RouteComponent() {
> >
<header className="flex py-2 shrink-0 items-center gap-2 border-b px-4 w-full"> <header className="flex py-2 shrink-0 items-center gap-2 border-b px-4 w-full">
<DirectoryPathBreadcrumb <DirectoryPathBreadcrumb
directory={directory}
rootLabel="Trash" rootLabel="Trash"
directoryUrlFn={directoryUrlById} directoryUrlFn={directoryUrlById}
/> />
@@ -119,13 +120,17 @@ function RouteComponent() {
<TableContextMenu> <TableContextMenu>
<div className="w-full"> <div className="w-full">
<DirectoryContentTable <WithAtom atom={optimisticRemovedItemsAtom}>
filterFn={() => true} {(optimisticRemovedItems) => (
directoryUrlFn={directoryUrlFn} <DirectoryContentTable
fileDragInfoAtom={fileDragInfoAtom} hiddenItems={optimisticRemovedItems}
onContextMenu={handleContextMenuRequest} directoryUrlFn={directoryUrlFn}
onOpenFile={setOpenedFile} fileDragInfoAtom={fileDragInfoAtom}
/> onContextMenu={handleContextMenuRequest}
onOpenFile={setOpenedFile}
/>
)}
</WithAtom>
</div> </div>
</TableContextMenu> </TableContextMenu>
@@ -173,14 +178,19 @@ function RestoreContextMenuItem() {
const store = useStore() const store = useStore()
const setOptimisticRemovedItems = useSetAtom(optimisticRemovedItemsAtom) const setOptimisticRemovedItems = useSetAtom(optimisticRemovedItemsAtom)
const restoreItemsMutation = useConvexMutation(api.filesystem.restoreItems) const restoreItemsMutation = useConvexMutation(api.filesystem.restoreItems)
const { mutate: restoreItems, isPending: isRestoring } = useMutation({
const { mutate: restoreItems } = useMutation({
mutationFn: restoreItemsMutation, mutationFn: restoreItemsMutation,
onMutate: ({ handles }) => { onMutate: ({ handles }) => {
setBackgroundTaskProgress({
label: "Restoring items…",
})
setOptimisticRemovedItems( setOptimisticRemovedItems(
new Set(handles.map((handle) => handle.id)), new Set(handles.map((handle) => handle.id)),
) )
}, },
onSuccess: ({ restored, errors }) => { onSuccess: ({ restored, errors }) => {
setBackgroundTaskProgress(null)
if (errors.length === 0) { if (errors.length === 0) {
if (restored.files > 0 && restored.directories > 0) { if (restored.files > 0 && restored.directories > 0) {
toast.success( toast.success(
@@ -199,19 +209,18 @@ function RestoreContextMenuItem() {
) )
} }
}, },
onError: (_err, { handles }) => {
setOptimisticRemovedItems((prev) => {
const newSet = new Set(prev)
for (const handle of handles) {
newSet.delete(handle.id)
}
return newSet
})
},
}) })
const setBackgroundTaskProgress = useSetAtom(backgroundTaskProgressAtom) const setBackgroundTaskProgress = useSetAtom(backgroundTaskProgressAtom)
useEffect(() => {
if (isRestoring) {
setBackgroundTaskProgress({
label: "Restoring items…",
})
} else {
setBackgroundTaskProgress(null)
}
}, [isRestoring, setBackgroundTaskProgress])
const onClick = () => { const onClick = () => {
const targetItems = store.get(contextMenuTargetItemsAtom) const targetItems = store.get(contextMenuTargetItemsAtom)
restoreItems({ restoreItems({