Update filemanager (🚧 in construction)
This commit is contained in:
parent
0fe469b5c6
commit
11dc6b2132
14 changed files with 925 additions and 223 deletions
271
client-electron/src/hooks/filemanager-android.ts
Normal file
271
client-electron/src/hooks/filemanager-android.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { create } from 'zustand'
|
||||
import useAdb from './useAdb'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { type LinuxFileType } from '@yume-chan/adb'
|
||||
import { fromUnixTime } from 'date-fns'
|
||||
import { Consumable, ReadableStream, WritableStream } from '@yume-chan/stream-extra'
|
||||
import JSZip from 'jszip'
|
||||
|
||||
export interface AndroidFile {
|
||||
filename: string
|
||||
type: LinuxFileType
|
||||
size: bigint
|
||||
dateModified: Date
|
||||
}
|
||||
|
||||
interface FileManagerAndroidHook {
|
||||
rootPath: string
|
||||
currentPath: string
|
||||
pushPath: (path: string) => void
|
||||
popPath: () => void
|
||||
setCurrentPath: (path: string) => void
|
||||
scanPath: (path?: string) => Promise<AndroidFile[] | undefined>
|
||||
pushFile: (file: File, targetPath: string) => Promise<void>
|
||||
pushFiles: (files: File[], targetPath: string) => Promise<void>
|
||||
createDirectory: (dirName: string) => Promise<void>
|
||||
delete: (filename: string) => Promise<void>
|
||||
rename: (filename: string, newName: string) => Promise<void>
|
||||
download: (files: AndroidFile[]) => Promise<void>
|
||||
}
|
||||
|
||||
const useFileManager = create<FileManagerAndroidHook>((set, get) => ({
|
||||
rootPath: '/mnt/sdcard/coffeevending',
|
||||
currentPath: '',
|
||||
pushPath(path) {
|
||||
set({ currentPath: get().currentPath + '/' + path })
|
||||
console.log('currentPath', get().currentPath)
|
||||
},
|
||||
popPath() {
|
||||
set({ currentPath: get().currentPath.slice(0, get().currentPath.lastIndexOf('/')) })
|
||||
console.log('currentPath', get().currentPath)
|
||||
},
|
||||
setCurrentPath(path) {
|
||||
set({ currentPath: path })
|
||||
},
|
||||
async scanPath(path) {
|
||||
const adb = useAdb.getState().adb
|
||||
if (!adb) {
|
||||
toast({
|
||||
duration: 3000,
|
||||
variant: 'destructive',
|
||||
title: 'Failed to connect to device',
|
||||
description: 'Please connect Adb first'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const sync = await adb.sync()
|
||||
try {
|
||||
console.log('scanning path', get().rootPath + path)
|
||||
const entries = await sync.readdir(get().rootPath + path)
|
||||
return entries.reduce<AndroidFile[]>((acc, entry) => {
|
||||
if (entry.name === '.' || entry.name === '..') {
|
||||
return acc
|
||||
}
|
||||
|
||||
return [
|
||||
...acc,
|
||||
{
|
||||
filename: entry.name,
|
||||
type: entry.type,
|
||||
size: entry.size,
|
||||
dateModified: fromUnixTime(Number(entry.mtime))
|
||||
}
|
||||
]
|
||||
}, [])
|
||||
} finally {
|
||||
await sync.dispose()
|
||||
}
|
||||
},
|
||||
async pushFile(file, targetPath) {
|
||||
const adb = useAdb.getState().adb
|
||||
if (!adb) {
|
||||
toast({
|
||||
duration: 3000,
|
||||
variant: 'destructive',
|
||||
title: 'Failed to connect to device',
|
||||
description: 'Please connect Adb first'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const buffer = await file.arrayBuffer()
|
||||
const sync = await adb.sync()
|
||||
try {
|
||||
await sync.write({
|
||||
filename: targetPath + '/' + file.name,
|
||||
file: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new Consumable(new Uint8Array(buffer)))
|
||||
controller.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
await sync.dispose()
|
||||
}
|
||||
},
|
||||
async pushFiles(files, targetPath) {
|
||||
for (const file of files) {
|
||||
await get().pushFile(file, targetPath)
|
||||
}
|
||||
},
|
||||
async createDirectory(name) {
|
||||
const adb = useAdb.getState().adb
|
||||
if (!adb) {
|
||||
toast({
|
||||
duration: 3000,
|
||||
variant: 'destructive',
|
||||
title: 'Failed to connect to device',
|
||||
description: 'Please connect Adb first'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const process = await adb.subprocess.spawn('mkdir ' + get().rootPath + '/' + name)
|
||||
process.stderr.pipeTo(
|
||||
new WritableStream({
|
||||
write(chunk) {
|
||||
console.error(chunk)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
process.stdout.pipeTo(
|
||||
new WritableStream({
|
||||
write(chunk) {
|
||||
console.log(chunk)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
if ((await process.exit) != 0) {
|
||||
toast({
|
||||
duration: 3000,
|
||||
variant: 'destructive',
|
||||
title: 'Failed to create directory',
|
||||
description: 'Please try again'
|
||||
})
|
||||
}
|
||||
},
|
||||
async delete(filename) {
|
||||
const adb = useAdb.getState().adb
|
||||
if (!adb) {
|
||||
toast({
|
||||
duration: 3000,
|
||||
variant: 'destructive',
|
||||
title: 'Failed to connect to device',
|
||||
description: 'Please connect Adb first'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const process = await adb.subprocess.spawn('rm ' + get().rootPath + '/' + filename)
|
||||
process.stderr.pipeTo(
|
||||
new WritableStream({
|
||||
write(chunk) {
|
||||
console.error(chunk)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
process.stdout.pipeTo(
|
||||
new WritableStream({
|
||||
write(chunk) {
|
||||
console.log(chunk)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
if ((await process.exit) != 0) {
|
||||
toast({
|
||||
duration: 3000,
|
||||
variant: 'destructive',
|
||||
title: 'Failed to delete file',
|
||||
description: 'Please try again'
|
||||
})
|
||||
}
|
||||
},
|
||||
async rename(filename, newName) {
|
||||
const adb = useAdb.getState().adb
|
||||
if (!adb) {
|
||||
toast({
|
||||
duration: 3000,
|
||||
variant: 'destructive',
|
||||
title: 'Failed to connect to device',
|
||||
description: 'Please connect Adb first'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const process = await adb.subprocess.spawn(
|
||||
'mv ' + get().rootPath + '/' + filename + ' ' + get().rootPath + '/' + newName
|
||||
)
|
||||
process.stderr.pipeTo(
|
||||
new WritableStream({
|
||||
write(chunk) {
|
||||
console.error(chunk)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
process.stdout.pipeTo(
|
||||
new WritableStream({
|
||||
write(chunk) {
|
||||
console.log(chunk)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
if ((await process.exit) != 0) {
|
||||
toast({
|
||||
duration: 3000,
|
||||
variant: 'destructive',
|
||||
title: 'Failed to rename file',
|
||||
description: 'Please try again'
|
||||
})
|
||||
}
|
||||
},
|
||||
async download(files) {
|
||||
const adb = useAdb.getState().adb
|
||||
if (!adb) {
|
||||
toast({
|
||||
duration: 3000,
|
||||
variant: 'destructive',
|
||||
title: 'Failed to connect to device',
|
||||
description: 'Please connect Adb first'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const sync = await adb.sync()
|
||||
try {
|
||||
const zip = new JSZip()
|
||||
for (const file of files) {
|
||||
const buffer: Uint8Array[] = []
|
||||
const fileStream = sync.read(get().rootPath + '/' + file.filename)
|
||||
await fileStream.pipeTo(
|
||||
new WritableStream({
|
||||
write(chunk) {
|
||||
buffer.push(chunk)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
zip.file(file.filename, new Blob(buffer))
|
||||
}
|
||||
|
||||
const blob = await zip.generateAsync({ type: 'blob' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'download.zip'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} finally {
|
||||
await sync.dispose()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
export default useFileManager
|
||||
|
|
@ -4,9 +4,9 @@ import App from './App.tsx'
|
|||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
// <React.StrictMode>
|
||||
<App />
|
||||
// </React.StrictMode>
|
||||
)
|
||||
|
||||
if (window.electronRuntime) {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ const AndroidPage: React.FC = () => {
|
|||
<ShellTab adb={adb} />
|
||||
</TabsContent>
|
||||
<TabsContent value="file-manager">
|
||||
<FileManagerTab adb={adb} />
|
||||
<FileManagerTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<Toaster />
|
||||
|
|
|
|||
|
|
@ -1,158 +1,97 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { LinuxFileType, type Adb } from '@yume-chan/adb'
|
||||
import { Consumable, WritableStream, ReadableStream } from '@yume-chan/stream-extra'
|
||||
import JSZip from 'jszip'
|
||||
import { useCallback, useState } from 'react'
|
||||
import useFileManager, { type AndroidFile } from '@/hooks/filemanager-android'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import DataTable from './filemanager-table/data-table'
|
||||
import { ChevronRightIcon, FileIcon } from '@radix-ui/react-icons'
|
||||
import { type ColumnDef } from '@tanstack/react-table'
|
||||
import { LinuxFileType } from '@yume-chan/adb'
|
||||
import { formatDate } from 'date-fns'
|
||||
import DataTableColumnHeader from './filemanager-table/data-table-column-header'
|
||||
import DataTableRowActions from './filemanager-table/data-table-row-actions'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
|
||||
interface FileManagerTabProps {
|
||||
adb: Adb | undefined
|
||||
}
|
||||
export const FileManagerTab: React.FC = () => {
|
||||
const { currentPath, pushPath, popPath, setCurrentPath, scanPath } = useFileManager(
|
||||
useShallow(state => ({
|
||||
currentPath: state.currentPath,
|
||||
pushPath: state.pushPath,
|
||||
popPath: state.popPath,
|
||||
setCurrentPath: state.setCurrentPath,
|
||||
scanPath: state.scanPath
|
||||
}))
|
||||
)
|
||||
|
||||
type filesType = {
|
||||
file: string
|
||||
indent: number
|
||||
blob?: Blob
|
||||
files?: filesType[]
|
||||
}
|
||||
|
||||
export const FileManagerTab: React.FC<FileManagerTabProps> = ({ adb }) => {
|
||||
const [path, setPath] = useState<string>('')
|
||||
const [pushPath, setPushPath] = useState<string>('')
|
||||
const [pushFile, setPushFile] = useState<File | null>()
|
||||
|
||||
const [files, setFiles] = useState<filesType>()
|
||||
|
||||
const pushFiles = async (filename: string, blob: Blob, targetPath: string) => {
|
||||
if (!adb) return
|
||||
|
||||
const buffer = await blob.arrayBuffer()
|
||||
console.log(blob, buffer, targetPath, filename)
|
||||
const sync = await adb.sync()
|
||||
try {
|
||||
await sync.write({
|
||||
filename: targetPath + '/' + filename,
|
||||
file: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new Consumable(new Uint8Array(buffer)))
|
||||
controller.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
await sync.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
const zipFiles = (files: filesType) => {
|
||||
const zip = new JSZip()
|
||||
|
||||
const addFiles = (folder: filesType, parent: JSZip | null) => {
|
||||
folder.files?.forEach(file => {
|
||||
if (file.files) {
|
||||
let newFolder
|
||||
if (parent) {
|
||||
newFolder = parent.folder(file.file)
|
||||
} else {
|
||||
newFolder = zip.folder(file.file)
|
||||
}
|
||||
addFiles(file, newFolder)
|
||||
} else {
|
||||
if (!file.blob) {
|
||||
return
|
||||
}
|
||||
if (parent) {
|
||||
parent.file(file.file, file.blob)
|
||||
} else {
|
||||
zip.file(file.file, file.blob)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
addFiles(files, zip.folder(files.file))
|
||||
zip.generateAsync({ type: 'blob' }).then(content => {
|
||||
const url = URL.createObjectURL(content)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = files.file + '.zip'
|
||||
a.click()
|
||||
useEffect(() => {
|
||||
console.log('scanning path', currentPath)
|
||||
scanPath(currentPath).then(files => {
|
||||
console.log(files)
|
||||
setFiles(files)
|
||||
setIsLoading(false)
|
||||
})
|
||||
}
|
||||
}, [currentPath])
|
||||
|
||||
const download = useCallback(() => {
|
||||
const readFiles = async (adb: Adb) => {
|
||||
const sync = await adb.sync()
|
||||
try {
|
||||
const folder: filesType = {
|
||||
file: path.split('/').pop() || 'scannedFiles',
|
||||
indent: 0,
|
||||
files: []
|
||||
}
|
||||
const [files, setFiles] = useState<AndroidFile[] | undefined>(undefined)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const TypeBlob: {
|
||||
[key: string]: string
|
||||
} = {
|
||||
mp4: 'video/mp4',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif'
|
||||
}
|
||||
|
||||
// scan all files in the folder
|
||||
const scanFiles = async (folder: filesType, path: string, indent: number) => {
|
||||
const entries = await sync.readdir(path)
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) {
|
||||
continue
|
||||
}
|
||||
if (entry.type === LinuxFileType.Directory) {
|
||||
const newFolder: filesType = {
|
||||
file: entry.name,
|
||||
indent: indent,
|
||||
files: []
|
||||
}
|
||||
folder.files?.push(newFolder)
|
||||
await scanFiles(newFolder, `${path}/${entry.name}`, indent + 1)
|
||||
} else {
|
||||
const buffer: Uint8Array[] = []
|
||||
const fileStream = sync.read(`${path}/${entry.name}`)
|
||||
await fileStream.pipeTo(
|
||||
new WritableStream({
|
||||
write(chunk) {
|
||||
buffer.push(chunk)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const blob = new Blob(buffer, { type: TypeBlob[entry.name.split('.').pop()!] })
|
||||
folder.files?.push({
|
||||
file: entry.name,
|
||||
blob: blob,
|
||||
indent: indent
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('Scanning files...')
|
||||
await scanFiles(folder, path, 1)
|
||||
console.log('Scanned files')
|
||||
setFiles(folder)
|
||||
console.log(folder)
|
||||
|
||||
// Zip files
|
||||
zipFiles(folder)
|
||||
} finally {
|
||||
await sync.dispose()
|
||||
}
|
||||
const columns: ColumnDef<AndroidFile>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate')}
|
||||
onCheckedChange={value => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
className="translate-y-[2px]"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={value => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
className="translate-y-[2px]"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false
|
||||
},
|
||||
{
|
||||
id: 'filename',
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Name" />,
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div
|
||||
className="flex hover:cursor-pointer"
|
||||
onClick={() => {
|
||||
if (row.original.type !== LinuxFileType.File) pushPath(row.original.filename)
|
||||
}}
|
||||
>
|
||||
{row.original.type === LinuxFileType.File && <FileIcon className="mr-2 h-4 w-4" />}
|
||||
<span className={row.original.type !== LinuxFileType.File ? 'hover:underline' : 'ml-2'}>
|
||||
{row.original.filename}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
enableSorting: true,
|
||||
enableHiding: false
|
||||
},
|
||||
{
|
||||
id: 'size',
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Size" />,
|
||||
cell: ({ row }) => <div>{row.original.size.toString()}</div>
|
||||
},
|
||||
{
|
||||
id: 'dateModified',
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />,
|
||||
cell: ({ row }) => <div>{formatDate(row.original.dateModified, 'dd MMM yyyy HH:mm:ss')}</div>
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => <DataTableRowActions row={row} />
|
||||
}
|
||||
|
||||
if (adb) {
|
||||
readFiles(adb)
|
||||
}
|
||||
}, [adb, path])
|
||||
]
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
|
@ -161,46 +100,34 @@ export const FileManagerTab: React.FC<FileManagerTabProps> = ({ adb }) => {
|
|||
<CardDescription>Manage files in Android</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex w-full flex-col items-center justify-end py-3">
|
||||
<div className="flex w-full items-center justify-around space-x-10">
|
||||
<div className="flex space-x-5">
|
||||
<Input placeholder="folder to download" value={path} onChange={e => setPath(e.target.value)} />
|
||||
<Button variant={'default'} onClick={download}>
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex space-x-5">
|
||||
<Input placeholder="path to push file" value={pushPath} onChange={e => setPushPath(e.target.value)} />
|
||||
<Input type="file" accept="*" dir="ltr" onChange={e => setPushFile(e.target.files?.item(0))} />
|
||||
<Button
|
||||
variant={'default'}
|
||||
onClick={
|
||||
() => pushFiles(pushFile?.name || 'unname', pushFile as Blob, pushPath)
|
||||
//testPushFile
|
||||
}
|
||||
>
|
||||
Push
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-96 w-full overflow-y-auto">{files && <FileTree files={files} />}</div>
|
||||
<div className="flex space-x-3">
|
||||
<span className="hover:underline" onClick={() => setCurrentPath('')}>
|
||||
ROOT
|
||||
</span>
|
||||
{currentPath &&
|
||||
currentPath.split('/').map((path, index) => {
|
||||
return (
|
||||
<div key={index} className="flex justify-center items-center space-x-3">
|
||||
<span
|
||||
className="hover:underline"
|
||||
onClick={() =>
|
||||
setCurrentPath(
|
||||
currentPath
|
||||
.split('/')
|
||||
.slice(0, index + 1)
|
||||
.join('/')
|
||||
)
|
||||
}
|
||||
>
|
||||
{path}
|
||||
</span>
|
||||
{currentPath.split('/').length - 1 !== index && <ChevronRightIcon />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<DataTable data={files ?? []} columns={columns} isLoading={isLoading} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const FileTree = ({ files }: { files: filesType }) => {
|
||||
return (
|
||||
<div className="pl-5">
|
||||
{files.files?.map((file, index) => {
|
||||
return (
|
||||
<div key={index}>
|
||||
<span>{file.file}</span>
|
||||
<div>{file.files && <FileTree files={file} />}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon, EyeNoneIcon } from '@radix-ui/react-icons'
|
||||
import { type Column } from '@tanstack/react-table'
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>
|
||||
title: string
|
||||
}
|
||||
|
||||
const DataTableColumnHeader = <TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) => {
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn(className)}>{title}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center space-x-2', className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="data-[state=open]:bg-accent -ml-3 h-8">
|
||||
<span>{title}</span>
|
||||
{column.getIsSorted() === 'desc' ? (
|
||||
<ArrowDownIcon className="ml-2 h-4 w-4" />
|
||||
) : column.getIsSorted() === 'asc' ? (
|
||||
<ArrowUpIcon className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<CaretSortIcon className="ml-2 h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUpIcon className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" />
|
||||
Asc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDownIcon className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" />
|
||||
Desc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||
<EyeNoneIcon className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" />
|
||||
Hide
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DataTableColumnHeader
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { PlusCircledIcon, CheckIcon } from '@radix-ui/react-icons'
|
||||
import { type Column } from '@tanstack/react-table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator
|
||||
} from '@/components/ui/command'
|
||||
|
||||
interface DataTableFacetedFilterProps<TData, TValue, TOption> {
|
||||
column?: Column<TData, TValue>
|
||||
title?: string
|
||||
options: TOption extends { value: string; label: string; icon?: React.ComponentType<{ className?: string }> }
|
||||
? TOption[]
|
||||
: never
|
||||
}
|
||||
|
||||
const DataTableFacetedFilter = <TData, TValue, TOption>({
|
||||
column,
|
||||
title,
|
||||
options
|
||||
}: DataTableFacetedFilterProps<TData, TValue, TOption>) => {
|
||||
const facets = column?.getFacetedUniqueValues()
|
||||
const selectedValues = new Set(column?.getFilterValue() as string[])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 border-dashed">
|
||||
<PlusCircledIcon className="mr-2 h-4 w-4" />
|
||||
{title}
|
||||
{selectedValues?.size > 0 && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<Badge variant="secondary" className="rounded-sm px-1 font-normal lg:hidden">
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className="hidden space-x-1 lg:flex">
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge variant="secondary" className="rounded-sm px-1 font-normal">
|
||||
{selectedValues.size} selected
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
.filter(option => selectedValues.has(option.value))
|
||||
.map(option => (
|
||||
<Badge variant="secondary" key={option.value} className="rounded-sm px-1 font-normal">
|
||||
{option.label}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={title} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map(option => {
|
||||
const isSelected = selectedValues.has(option.value)
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => {
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value)
|
||||
} else {
|
||||
selectedValues.add(option.value)
|
||||
}
|
||||
const filterValues = Array.from(selectedValues)
|
||||
column?.setFilterValue(filterValues.length ? filterValues : undefined)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'border-primary mr-2 flex h-4 w-4 items-center justify-center rounded-sm border',
|
||||
isSelected ? 'bg-primary text-primary-foreground' : 'opacity-50 [&_svg]:invisible'
|
||||
)}
|
||||
>
|
||||
<CheckIcon className={cn('h-4 w-4')} />
|
||||
</div>
|
||||
{option.icon && <option.icon className="text-muted-foreground mr-2 h-4 w-4" />}
|
||||
<span>{option.label}</span>
|
||||
{facets?.get(option.value) && (
|
||||
<span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
|
||||
{facets.get(option.value)}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
{selectedValues.size > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => column?.setFilterValue(undefined)}
|
||||
className="justify-center text-center"
|
||||
>
|
||||
Clear filters
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default DataTableFacetedFilter
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { DoubleArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon, DoubleArrowRightIcon } from '@radix-ui/react-icons'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
const DataTablePagination = <TData,>({ table }: DataTablePaginationProps<TData>) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm font-medium">Rows per page</p>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={value => {
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 20, 30, 40, 50].map(pageSize => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<DoubleArrowLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<DoubleArrowRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DataTablePagination
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { type Row } from '@tanstack/react-table'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DotsHorizontalIcon } from '@radix-ui/react-icons'
|
||||
import { labels } from '@/models/data'
|
||||
|
||||
interface DataTableRowActionsProps<TData> {
|
||||
row: Row<TData>
|
||||
}
|
||||
|
||||
const DataTableRowActions = <TData,>({ row }: DataTableRowActionsProps<TData>) => {
|
||||
const task = {
|
||||
label: 'none'
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="data-[state=open]:bg-muted flex h-8 w-8 p-0">
|
||||
<DotsHorizontalIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[160px]">
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Make a copy</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Labels</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup value={task.label}>
|
||||
{labels.map(label => (
|
||||
<DropdownMenuRadioItem key={label.value} value={label.value}>
|
||||
{label.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
Delete
|
||||
<DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default DataTableRowActions
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import DataTableViewOptions from './data-table-view-options'
|
||||
import { Cross2Icon } from '@radix-ui/react-icons'
|
||||
|
||||
interface DataTableToolbarProps<TData> {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
const DataTableToolbar = <TData,>({ table }: DataTableToolbarProps<TData>) => {
|
||||
const isFiltered = table.getState().columnFilters.length > 0
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex w-full flex-col space-y-4 rounded-lg bg-white p-3 shadow-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-1 items-center space-x-2">
|
||||
{isFiltered && (
|
||||
<Button variant="ghost" onClick={() => table.resetColumnFilters()} className="h-8 px-2 lg:px-3">
|
||||
Reset
|
||||
<Cross2Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DataTableViewOptions table={table} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DataTableToolbar
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { MixerHorizontalIcon } from '@radix-ui/react-icons'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
|
||||
interface DataTableViewOptionsProps<TData> {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
const DataTableViewOptions = <TData,>({ table }: DataTableViewOptionsProps<TData>) => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="ml-auto hidden h-8 lg:flex">
|
||||
<MixerHorizontalIcon className="mr-2 h-4 w-4" />
|
||||
View
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[150px]">
|
||||
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(column => typeof column.accessorFn !== 'undefined' && column.getCanHide())
|
||||
.map(column => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={value => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default DataTableViewOptions
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import type { VisibilityState, SortingState, ColumnDef, FilterFn, ColumnFiltersState } from '@tanstack/react-table'
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
flexRender
|
||||
} from '@tanstack/react-table'
|
||||
import { rankItem } from '@tanstack/match-sorter-utils'
|
||||
import { useState } from 'react'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import DataTablePagination from './data-table-pagination'
|
||||
import { ReloadIcon } from '@radix-ui/react-icons'
|
||||
import DataTableToolbar from './data-table-toolbar'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
|
||||
// Rank the item
|
||||
const itemRank = rankItem(row.getValue(columnId), value)
|
||||
|
||||
// Store the itemRank info
|
||||
addMeta({
|
||||
itemRank
|
||||
})
|
||||
|
||||
// Return if the item should be filtered in/out
|
||||
return itemRank.passed
|
||||
}
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[]
|
||||
data: TData[]
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
const DataTable = <TData, TValue>({ columns, data, isLoading }: DataTableProps<TData, TValue>) => {
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
filterFns: {
|
||||
fuzzy: fuzzyFilter
|
||||
},
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters
|
||||
},
|
||||
globalFilterFn: fuzzyFilter,
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues()
|
||||
})
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DataTableToolbar table={table} />
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map(headerGroup => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map(header => {
|
||||
return (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map(row => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
|
||||
{row.getVisibleCells().map(cell => (
|
||||
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="flex h-24 space-x-5 text-center">
|
||||
<span>Loading...</span>
|
||||
<ReloadIcon className="h-5 w-5 animate-spin" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DataTable
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -8,52 +7,23 @@ import {
|
|||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ArrowDownIcon, ArrowUpIcon, CalendarIcon, CaretSortIcon, EyeNoneIcon } from '@radix-ui/react-icons'
|
||||
import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon, EyeNoneIcon } from '@radix-ui/react-icons'
|
||||
import { type Column } from '@tanstack/react-table'
|
||||
import { DateRange } from 'react-day-picker'
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>
|
||||
title: string
|
||||
isDate?: boolean
|
||||
}
|
||||
|
||||
const DataTableColumnHeader = <TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
isDate,
|
||||
className
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) => {
|
||||
if (!column.getCanSort() && !isDate) {
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn(className)}>{title}</div>
|
||||
}
|
||||
|
||||
if (isDate) {
|
||||
return (
|
||||
<div className={cn('flex items-center space-x-2', className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="data-[state=open]:bg-accent -ml-3 h-8">
|
||||
<span>{title}</span>
|
||||
<CalendarIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center">
|
||||
<Calendar
|
||||
mode="range"
|
||||
defaultMonth={new Date('2022-01-01')}
|
||||
selected={column.getFilterValue() as DateRange}
|
||||
onSelect={date => column.setFilterValue(date)}
|
||||
numberOfMonths={2}
|
||||
disabled={date => date > new Date() || date < new Date('1900-01-01')}
|
||||
initialFocus
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center space-x-2', className)}>
|
||||
<DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ const DataTableRowActions = <TData,>({ row }: DataTableRowActionsProps<TData>) =
|
|||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Labels</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup value={task.label}>
|
||||
<DropdownMenuRadioGroup value={task.name}>
|
||||
{labels.map(label => (
|
||||
<DropdownMenuRadioItem key={label.value} value={label.value}>
|
||||
{label.label}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
|
|||
import DataTablePagination from './data-table-pagination'
|
||||
import { ReloadIcon } from '@radix-ui/react-icons'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const fuzzyFilter: FilterFn<any> = (row, columnId, value, addMeta) => {
|
||||
// Rank the item
|
||||
const itemRank = rankItem(row.getValue(columnId), value)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue