feat-dev(wip): files table view (#2100)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
|
||||
export enum PopoverFileItemActionType {
|
||||
export enum FileItemActionType {
|
||||
AttachFileToNote,
|
||||
DetachFileToNote,
|
||||
DeleteFile,
|
||||
@@ -10,34 +10,32 @@ export enum PopoverFileItemActionType {
|
||||
PreviewFile,
|
||||
}
|
||||
|
||||
export type PopoverFileItemAction =
|
||||
export type FileItemAction =
|
||||
| {
|
||||
type: Exclude<
|
||||
PopoverFileItemActionType,
|
||||
| PopoverFileItemActionType.RenameFile
|
||||
| PopoverFileItemActionType.ToggleFileProtection
|
||||
| PopoverFileItemActionType.PreviewFile
|
||||
FileItemActionType,
|
||||
FileItemActionType.RenameFile | FileItemActionType.ToggleFileProtection | FileItemActionType.PreviewFile
|
||||
>
|
||||
payload: {
|
||||
file: FileItem
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: PopoverFileItemActionType.ToggleFileProtection
|
||||
type: FileItemActionType.ToggleFileProtection
|
||||
payload: {
|
||||
file: FileItem
|
||||
}
|
||||
callback: (isProtected: boolean) => void
|
||||
}
|
||||
| {
|
||||
type: PopoverFileItemActionType.RenameFile
|
||||
type: FileItemActionType.RenameFile
|
||||
payload: {
|
||||
file: FileItem
|
||||
name: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: PopoverFileItemActionType.PreviewFile
|
||||
type: FileItemActionType.PreviewFile
|
||||
payload: {
|
||||
file: FileItem
|
||||
otherFiles?: FileItem[]
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@standardnotes/ui-services'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { PANEL_NAME_NOTES } from '@/Constants/Constants'
|
||||
import { FileItem, PrefKey, WebAppEvent } from '@standardnotes/snjs'
|
||||
import { FileItem, PrefKey, SystemViewId, WebAppEvent } from '@standardnotes/snjs'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { forwardRef, useCallback, useEffect, useMemo } from 'react'
|
||||
import ContentList from '@/Components/ContentListView/ContentList'
|
||||
@@ -37,6 +37,9 @@ import { PanelResizedData } from '@/Types/PanelResizedData'
|
||||
import { useForwardedRef } from '@/Hooks/useForwardedRef'
|
||||
import { isMobileScreen } from '@/Utils'
|
||||
import FloatingAddButton from './FloatingAddButton'
|
||||
import FilesTableView from '../FilesTableView/FilesTableView'
|
||||
import { FeaturesController } from '@/Controllers/FeaturesController'
|
||||
import { featureTrunkEnabled, FeatureTrunkName } from '@/FeatureTrunk'
|
||||
|
||||
type Props = {
|
||||
accountMenuController: AccountMenuController
|
||||
@@ -49,6 +52,7 @@ type Props = {
|
||||
selectionController: SelectedItemsController
|
||||
searchOptionsController: SearchOptionsController
|
||||
linkingController: LinkingController
|
||||
featuresController: FeaturesController
|
||||
className?: string
|
||||
id: string
|
||||
children?: React.ReactNode
|
||||
@@ -68,6 +72,7 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
|
||||
selectionController,
|
||||
searchOptionsController,
|
||||
linkingController,
|
||||
featuresController,
|
||||
className,
|
||||
id,
|
||||
children,
|
||||
@@ -280,6 +285,9 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
|
||||
}
|
||||
}, [selectedUuids, innerRef, isCurrentNoteTemplate, renderedItems, panes])
|
||||
|
||||
const isFilesTableViewEnabled = featureTrunkEnabled(FeatureTrunkName.FilesTableView)
|
||||
const shouldShowFilesTableView = isFilesTableViewEnabled && selectedTag?.uuid === SystemViewId.Files
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
@@ -300,12 +308,16 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
|
||||
addButtonLabel={addButtonLabel}
|
||||
addNewItem={addNewItem}
|
||||
isFilesSmartView={isFilesSmartView}
|
||||
isFilesTableViewEnabled={isFilesTableViewEnabled}
|
||||
optionsSubtitle={optionsSubtitle}
|
||||
selectedTag={selectedTag}
|
||||
filesController={filesController}
|
||||
itemListController={itemListController}
|
||||
/>
|
||||
)}
|
||||
<SearchBar itemListController={itemListController} searchOptionsController={searchOptionsController} />
|
||||
{!isFilesTableViewEnabled && (
|
||||
<SearchBar itemListController={itemListController} searchOptionsController={searchOptionsController} />
|
||||
)}
|
||||
<NoAccountWarning
|
||||
accountMenuController={accountMenuController}
|
||||
noAccountWarningController={noAccountWarningController}
|
||||
@@ -324,13 +336,18 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
|
||||
{!dailyMode && completedFullSync && !renderedItems.length ? (
|
||||
<p className="empty-items-list opacity-50">No items.</p>
|
||||
) : null}
|
||||
|
||||
{!dailyMode && !completedFullSync && !renderedItems.length ? (
|
||||
<p className="empty-items-list opacity-50">Loading...</p>
|
||||
) : null}
|
||||
|
||||
{!dailyMode && renderedItems.length ? (
|
||||
<>
|
||||
shouldShowFilesTableView ? (
|
||||
<FilesTableView
|
||||
application={application}
|
||||
filesController={filesController}
|
||||
featuresController={featuresController}
|
||||
linkingController={linkingController}
|
||||
/>
|
||||
) : (
|
||||
<ContentList
|
||||
items={renderedItems}
|
||||
selectedUuids={selectedUuids}
|
||||
@@ -342,7 +359,7 @@ const ContentListView = forwardRef<HTMLDivElement, Props>(
|
||||
notesController={notesController}
|
||||
selectionController={selectionController}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
) : null}
|
||||
<div className="absolute bottom-0 h-safe-bottom w-full" />
|
||||
{children}
|
||||
|
||||
@@ -44,12 +44,8 @@ const AddItemMenuButton = ({
|
||||
<>
|
||||
<button
|
||||
className={classNames(
|
||||
'hidden md:flex',
|
||||
'h-8 w-8 hover:brightness-125',
|
||||
'z-editor-title-bar ml-3 cursor-pointer items-center',
|
||||
`justify-center rounded-full border border-solid border-transparent ${
|
||||
isDailyEntry ? 'bg-danger text-danger-contrast' : 'bg-info text-info-contrast'
|
||||
}`,
|
||||
'z-editor-title-bar hidden h-8 w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-transparent hover:brightness-125 md:flex',
|
||||
isDailyEntry ? 'bg-danger text-danger-contrast' : 'bg-info text-info-contrast',
|
||||
)}
|
||||
title={addButtonLabel}
|
||||
aria-label={addButtonLabel}
|
||||
|
||||
@@ -11,6 +11,8 @@ import { AnyTag } from '@/Controllers/Navigation/AnyTagType'
|
||||
import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
|
||||
import AddItemMenuButton from './AddItemMenuButton'
|
||||
import { FilesController } from '@/Controllers/FilesController'
|
||||
import SearchButton from './SearchButton'
|
||||
import { ItemListController } from '@/Controllers/ItemList/ItemListController'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
@@ -19,9 +21,11 @@ type Props = {
|
||||
addButtonLabel: string
|
||||
addNewItem: () => void
|
||||
isFilesSmartView: boolean
|
||||
isFilesTableViewEnabled: boolean
|
||||
optionsSubtitle?: string
|
||||
selectedTag: AnyTag
|
||||
filesController: FilesController
|
||||
itemListController: ItemListController
|
||||
}
|
||||
|
||||
const ContentListHeader = ({
|
||||
@@ -31,9 +35,11 @@ const ContentListHeader = ({
|
||||
addButtonLabel,
|
||||
addNewItem,
|
||||
isFilesSmartView,
|
||||
isFilesTableViewEnabled,
|
||||
optionsSubtitle,
|
||||
selectedTag,
|
||||
filesController,
|
||||
itemListController,
|
||||
}: Props) => {
|
||||
const displayOptionsContainerRef = useRef<HTMLDivElement>(null)
|
||||
const displayOptionsButtonRef = useRef<HTMLButtonElement>(null)
|
||||
@@ -98,6 +104,14 @@ const ContentListHeader = ({
|
||||
)
|
||||
}, [addButtonLabel, addNewItem, filesController, isDailyEntry, isFilesSmartView])
|
||||
|
||||
const SearchBarButton = useMemo(() => {
|
||||
if (!isFilesSmartView || !isFilesTableViewEnabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <SearchButton itemListController={itemListController} />
|
||||
}, [isFilesSmartView, isFilesTableViewEnabled, itemListController])
|
||||
|
||||
const FolderName = useMemo(() => {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-grow flex-col break-words pt-1 lg:pt-0">
|
||||
@@ -125,13 +139,14 @@ const ContentListHeader = ({
|
||||
<div className={'flex w-full justify-between md:flex'}>
|
||||
<NavigationMenuButton />
|
||||
{FolderName}
|
||||
<div className="flex">
|
||||
<div className="flex items-center gap-3">
|
||||
{SearchBarButton}
|
||||
{OptionsMenu}
|
||||
{AddButton}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, [OptionsMenu, AddButton, FolderName])
|
||||
}, [FolderName, SearchBarButton, OptionsMenu, AddButton])
|
||||
|
||||
const TabletLayout = useMemo(() => {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import RoundIconButton from '@/Components/Button/RoundIconButton'
|
||||
import ClearInputButton from '@/Components/ClearInputButton/ClearInputButton'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import DecoratedInput from '@/Components/Input/DecoratedInput'
|
||||
import { ElementIds } from '@/Constants/ElementIDs'
|
||||
import { ItemListController } from '@/Controllers/ItemList/ItemListController'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useState } from 'react'
|
||||
|
||||
type Props = {
|
||||
itemListController: ItemListController
|
||||
}
|
||||
|
||||
const SearchButton = ({ itemListController }: Props) => {
|
||||
const { noteFilterText, setNoteFilterText, clearFilterText } = itemListController
|
||||
|
||||
const [isSearchBarVisible, setIsSearchBarVisible] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSearchBarVisible && (
|
||||
<DecoratedInput
|
||||
autocomplete={false}
|
||||
id={ElementIds.SearchBar}
|
||||
className={{
|
||||
container: 'px-1',
|
||||
input: 'text-base placeholder:text-passive-0 lg:text-sm',
|
||||
}}
|
||||
placeholder={'Search...'}
|
||||
value={noteFilterText}
|
||||
ref={(node) => {
|
||||
if (node && document.activeElement !== node) {
|
||||
node.focus()
|
||||
}
|
||||
}}
|
||||
onChange={(query) => setNoteFilterText(query)}
|
||||
left={[<Icon type="search" className="mr-1 h-4.5 w-4.5 flex-shrink-0 text-passive-1" />]}
|
||||
right={[noteFilterText && <ClearInputButton onClick={clearFilterText} />]}
|
||||
roundedFull
|
||||
/>
|
||||
)}
|
||||
<RoundIconButton
|
||||
onClick={() => {
|
||||
setIsSearchBarVisible(!isSearchBarVisible)
|
||||
}}
|
||||
icon={isSearchBarVisible ? 'close' : 'search'}
|
||||
label="Display options menu"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(SearchButton)
|
||||
@@ -13,6 +13,7 @@ type Props = {
|
||||
|
||||
const FileContextMenu: FunctionComponent<Props> = observer(({ filesController, selectionController }) => {
|
||||
const { showFileContextMenu, setShowFileContextMenu, fileContextMenuLocation } = filesController
|
||||
const { selectedFiles } = selectionController
|
||||
|
||||
return (
|
||||
<Popover
|
||||
@@ -25,7 +26,7 @@ const FileContextMenu: FunctionComponent<Props> = observer(({ filesController, s
|
||||
<Menu a11yLabel="File context menu" isOpen={showFileContextMenu}>
|
||||
<FileMenuOptions
|
||||
filesController={filesController}
|
||||
selectionController={selectionController}
|
||||
selectedFiles={selectedFiles}
|
||||
closeMenu={() => setShowFileContextMenu(false)}
|
||||
shouldShowRenameOption={false}
|
||||
shouldShowAttachOption={false}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { FunctionComponent, useCallback, useMemo } from 'react'
|
||||
import { PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
|
||||
import { FileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FilesController } from '@/Controllers/FilesController'
|
||||
import { SelectedItemsController } from '@/Controllers/SelectedItemsController'
|
||||
import HorizontalSeparator from '../Shared/HorizontalSeparator'
|
||||
import { formatSizeToReadableString } from '@standardnotes/filepicker'
|
||||
import { useResponsiveAppPane } from '../Panes/ResponsivePaneProvider'
|
||||
@@ -11,27 +10,27 @@ import { AppPaneId } from '../Panes/AppPaneMetadata'
|
||||
import MenuItem from '../Menu/MenuItem'
|
||||
import { FileContextMenuBackupOption } from './FileContextMenuBackupOption'
|
||||
import MenuSwitchButtonItem from '../Menu/MenuSwitchButtonItem'
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
|
||||
type Props = {
|
||||
closeMenu: () => void
|
||||
filesController: FilesController
|
||||
selectionController: SelectedItemsController
|
||||
isFileAttachedToNote?: boolean
|
||||
renameToggleCallback?: (isRenamingFile: boolean) => void
|
||||
shouldShowRenameOption: boolean
|
||||
shouldShowAttachOption: boolean
|
||||
selectedFiles: FileItem[]
|
||||
}
|
||||
|
||||
const FileMenuOptions: FunctionComponent<Props> = ({
|
||||
closeMenu,
|
||||
filesController,
|
||||
selectionController,
|
||||
isFileAttachedToNote,
|
||||
renameToggleCallback,
|
||||
shouldShowRenameOption,
|
||||
shouldShowAttachOption,
|
||||
selectedFiles,
|
||||
}) => {
|
||||
const { selectedFiles } = selectionController
|
||||
const { handleFileAction } = filesController
|
||||
const { toggleAppPane } = useResponsiveAppPane()
|
||||
|
||||
@@ -46,7 +45,7 @@ const FileMenuOptions: FunctionComponent<Props> = ({
|
||||
const onDetach = useCallback(() => {
|
||||
const file = selectedFiles[0]
|
||||
void handleFileAction({
|
||||
type: PopoverFileItemActionType.DetachFileToNote,
|
||||
type: FileItemActionType.DetachFileToNote,
|
||||
payload: { file },
|
||||
})
|
||||
closeMenu()
|
||||
@@ -55,7 +54,7 @@ const FileMenuOptions: FunctionComponent<Props> = ({
|
||||
const onAttach = useCallback(() => {
|
||||
const file = selectedFiles[0]
|
||||
void handleFileAction({
|
||||
type: PopoverFileItemActionType.AttachFileToNote,
|
||||
type: FileItemActionType.AttachFileToNote,
|
||||
payload: { file },
|
||||
})
|
||||
closeMenu()
|
||||
@@ -86,7 +85,7 @@ const FileMenuOptions: FunctionComponent<Props> = ({
|
||||
<MenuSwitchButtonItem
|
||||
checked={hasProtectedFiles}
|
||||
onChange={(hasProtectedFiles) => {
|
||||
void filesController.setProtectionForFiles(hasProtectedFiles, selectionController.selectedFiles)
|
||||
void filesController.setProtectionForFiles(hasProtectedFiles, selectedFiles)
|
||||
}}
|
||||
>
|
||||
<Icon type="lock" className="mr-2 text-neutral" />
|
||||
@@ -95,7 +94,7 @@ const FileMenuOptions: FunctionComponent<Props> = ({
|
||||
<HorizontalSeparator classes="my-1" />
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
void filesController.downloadFiles(selectionController.selectedFiles)
|
||||
void filesController.downloadFiles(selectedFiles)
|
||||
}}
|
||||
>
|
||||
<Icon type="download" className="mr-2 text-neutral" />
|
||||
@@ -114,7 +113,7 @@ const FileMenuOptions: FunctionComponent<Props> = ({
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
closeMenuAndToggleFilesList()
|
||||
void filesController.deleteFilesPermanently(selectionController.selectedFiles)
|
||||
void filesController.deleteFilesPermanently(selectedFiles)
|
||||
}}
|
||||
>
|
||||
<Icon type="trash" className="mr-2 text-danger" />
|
||||
|
||||
@@ -25,7 +25,7 @@ const FilesOptionsPanel = ({ filesController, selectionController }: Props) => {
|
||||
<Menu a11yLabel="File options panel" isOpen={isOpen}>
|
||||
<FileMenuOptions
|
||||
filesController={filesController}
|
||||
selectionController={selectionController}
|
||||
selectedFiles={selectionController.selectedFiles}
|
||||
closeMenu={() => {
|
||||
setIsOpen(false)
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { PrefDefaults } from '@/Constants/PrefDefaults'
|
||||
import { FilesController } from '@/Controllers/FilesController'
|
||||
import { formatDateForContextMenu } from '@/Utils/DateUtils'
|
||||
import { getIconForFileType } from '@/Utils/Items/Icons/getIconForFileType'
|
||||
import { formatSizeToReadableString } from '@standardnotes/filepicker'
|
||||
import { ContentType, FileItem, SortableItem, PrefKey, ApplicationEvent, naturalSort } from '@standardnotes/snjs'
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { FileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
|
||||
import { getFileIconComponent } from '../FilePreview/getFileIconComponent'
|
||||
import Popover from '../Popover/Popover'
|
||||
import Table from '../Table/Table'
|
||||
import { TableColumn } from '../Table/CommonTypes'
|
||||
import { useTable } from '../Table/useTable'
|
||||
import Menu from '../Menu/Menu'
|
||||
import FileMenuOptions from '../FileContextMenu/FileMenuOptions'
|
||||
import Icon from '../Icon/Icon'
|
||||
import { createLinkFromItem } from '@/Utils/Items/Search/createLinkFromItem'
|
||||
import LinkedItemBubble from '../LinkedItems/LinkedItemBubble'
|
||||
import LinkedItemsPanel from '../LinkedItems/LinkedItemsPanel'
|
||||
import { LinkingController } from '@/Controllers/LinkingController'
|
||||
import { FeaturesController } from '@/Controllers/FeaturesController'
|
||||
|
||||
const ContextMenuCell = ({ files, filesController }: { files: FileItem[]; filesController: FilesController }) => {
|
||||
const [contextMenuVisible, setContextMenuVisible] = useState(false)
|
||||
const anchorElementRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="rounded-full border border-border bg-default p-1"
|
||||
ref={anchorElementRef}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setContextMenuVisible((visible) => !visible)
|
||||
}}
|
||||
>
|
||||
<Icon type="more" />
|
||||
</button>
|
||||
<Popover
|
||||
open={contextMenuVisible}
|
||||
anchorElement={anchorElementRef.current}
|
||||
togglePopover={() => {
|
||||
setContextMenuVisible(false)
|
||||
}}
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="py-2"
|
||||
>
|
||||
<Menu a11yLabel="File context menu" isOpen={contextMenuVisible}>
|
||||
<FileMenuOptions
|
||||
closeMenu={() => {
|
||||
setContextMenuVisible(false)
|
||||
}}
|
||||
filesController={filesController}
|
||||
shouldShowRenameOption={false}
|
||||
shouldShowAttachOption={false}
|
||||
selectedFiles={files}
|
||||
/>
|
||||
</Menu>
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const FileLinksCell = ({
|
||||
file,
|
||||
filesController,
|
||||
linkingController,
|
||||
featuresController,
|
||||
}: {
|
||||
file: FileItem
|
||||
filesController: FilesController
|
||||
linkingController: LinkingController
|
||||
featuresController: FeaturesController
|
||||
}) => {
|
||||
const [contextMenuVisible, setContextMenuVisible] = useState(false)
|
||||
const anchorElementRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="rounded-full border border-border bg-default p-1"
|
||||
ref={anchorElementRef}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setContextMenuVisible((visible) => !visible)
|
||||
}}
|
||||
>
|
||||
<Icon type="link" />
|
||||
</button>
|
||||
<Popover
|
||||
open={contextMenuVisible}
|
||||
anchorElement={anchorElementRef.current}
|
||||
togglePopover={() => {
|
||||
setContextMenuVisible(false)
|
||||
}}
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="py-2"
|
||||
>
|
||||
<LinkedItemsPanel
|
||||
linkingController={linkingController}
|
||||
filesController={filesController}
|
||||
featuresController={featuresController}
|
||||
isOpen={contextMenuVisible}
|
||||
item={file}
|
||||
/>
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
filesController: FilesController
|
||||
featuresController: FeaturesController
|
||||
linkingController: LinkingController
|
||||
}
|
||||
|
||||
const FilesTableView = ({ application, filesController, featuresController, linkingController }: Props) => {
|
||||
const files = application.items
|
||||
.getDisplayableNotesAndFiles()
|
||||
.filter((item) => item.content_type === ContentType.File) as FileItem[]
|
||||
|
||||
const [sortBy, setSortBy] = useState<keyof SortableItem>(
|
||||
application.getPreference(PrefKey.SortNotesBy, PrefDefaults[PrefKey.SortNotesBy]),
|
||||
)
|
||||
const [sortReversed, setSortReversed] = useState(
|
||||
application.getPreference(PrefKey.SortNotesReverse, PrefDefaults[PrefKey.SortNotesReverse]),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return application.addEventObserver(async (event) => {
|
||||
if (event === ApplicationEvent.PreferencesChanged) {
|
||||
setSortBy(application.getPreference(PrefKey.SortNotesBy, PrefDefaults[PrefKey.SortNotesBy]))
|
||||
setSortReversed(application.getPreference(PrefKey.SortNotesReverse, PrefDefaults[PrefKey.SortNotesReverse]))
|
||||
}
|
||||
})
|
||||
}, [application])
|
||||
|
||||
const onSortChange = useCallback(
|
||||
async (sortBy: keyof SortableItem, sortReversed: boolean) => {
|
||||
await application.setPreference(PrefKey.SortNotesBy, sortBy)
|
||||
await application.setPreference(PrefKey.SortNotesReverse, sortReversed)
|
||||
},
|
||||
[application],
|
||||
)
|
||||
|
||||
const [contextMenuFile, setContextMenuFile] = useState<FileItem | undefined>(undefined)
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | undefined>(undefined)
|
||||
|
||||
const columnDefs: TableColumn<FileItem>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: 'Name',
|
||||
sortBy: 'title',
|
||||
cell: (file) => {
|
||||
return (
|
||||
<div className="flex max-w-[40vw] items-center gap-3 whitespace-normal">
|
||||
{getFileIconComponent(getIconForFileType(file.mimeType), 'w-6 h-6 flex-shrink-0')}
|
||||
<span className="text-sm font-medium">{file.title}</span>
|
||||
{file.protected && (
|
||||
<span className="flex items-center" title="File is protected">
|
||||
<Icon
|
||||
ariaLabel="File is protected"
|
||||
type="lock-filled"
|
||||
className="h-3.5 w-3.5 text-passive-1"
|
||||
size="custom"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Upload date',
|
||||
sortBy: 'created_at',
|
||||
cell: (file) => {
|
||||
return formatDateForContextMenu(file.created_at)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Size',
|
||||
cell: (file) => {
|
||||
return formatSizeToReadableString(file.decryptedSize)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Attached to',
|
||||
cell: (file) => {
|
||||
const links = [
|
||||
...naturalSort(application.items.referencesForItem(file), 'title').map((item) =>
|
||||
createLinkFromItem(item, 'linked'),
|
||||
),
|
||||
...naturalSort(application.items.itemsReferencingItem(file), 'title').map((item) =>
|
||||
createLinkFromItem(item, 'linked-by'),
|
||||
),
|
||||
...application.items.getSortedTagsForItem(file).map((item) => createLinkFromItem(item, 'linked')),
|
||||
]
|
||||
|
||||
if (!links.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex max-w-76 flex-wrap gap-2">
|
||||
<LinkedItemBubble
|
||||
className="overflow-hidden"
|
||||
link={links[0]}
|
||||
key={links[0].id}
|
||||
unlinkItem={async (itemToUnlink) => {
|
||||
void application.items.unlinkItems(file, itemToUnlink)
|
||||
}}
|
||||
isBidirectional={false}
|
||||
/>
|
||||
{links.length > 1 && <span>and {links.length - 1} more...</span>}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[application.items],
|
||||
)
|
||||
|
||||
const getRowId = useCallback((file: FileItem) => file.uuid, [])
|
||||
|
||||
const table = useTable({
|
||||
data: files,
|
||||
sortBy,
|
||||
sortReversed,
|
||||
onSortChange,
|
||||
getRowId,
|
||||
columns: columnDefs,
|
||||
enableRowSelection: true,
|
||||
enableMultipleRowSelection: true,
|
||||
onRowDoubleClick(file) {
|
||||
void filesController.handleFileAction({
|
||||
type: FileItemActionType.PreviewFile,
|
||||
payload: {
|
||||
file,
|
||||
},
|
||||
})
|
||||
},
|
||||
onRowContextMenu(x, y, file) {
|
||||
setContextMenuPosition({ x, y })
|
||||
setContextMenuFile(file)
|
||||
},
|
||||
rowActions: (file) => {
|
||||
const links = [
|
||||
...naturalSort(application.items.referencesForItem(file), 'title').map((item) =>
|
||||
createLinkFromItem(item, 'linked'),
|
||||
),
|
||||
...naturalSort(application.items.itemsReferencingItem(file), 'title').map((item) =>
|
||||
createLinkFromItem(item, 'linked-by'),
|
||||
),
|
||||
...application.items.getSortedTagsForItem(file).map((item) => createLinkFromItem(item, 'linked')),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{links.length > 0 && (
|
||||
<FileLinksCell
|
||||
file={file}
|
||||
filesController={filesController}
|
||||
featuresController={featuresController}
|
||||
linkingController={linkingController}
|
||||
/>
|
||||
)}
|
||||
<ContextMenuCell files={[file]} filesController={filesController} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
selectionActions: (fileIds) => (
|
||||
<ContextMenuCell files={files.filter((file) => fileIds.includes(file.uuid))} filesController={filesController} />
|
||||
),
|
||||
showSelectionActions: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table table={table} />
|
||||
{contextMenuPosition && contextMenuFile && (
|
||||
<Popover
|
||||
open={true}
|
||||
anchorPoint={contextMenuPosition}
|
||||
togglePopover={() => {
|
||||
setContextMenuPosition(undefined)
|
||||
setContextMenuFile(undefined)
|
||||
}}
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="py-2"
|
||||
>
|
||||
<Menu a11yLabel="File context menu" isOpen={true}>
|
||||
<FileMenuOptions
|
||||
closeMenu={() => {
|
||||
setContextMenuPosition(undefined)
|
||||
setContextMenuFile(undefined)
|
||||
}}
|
||||
filesController={filesController}
|
||||
shouldShowRenameOption={false}
|
||||
shouldShowAttachOption={false}
|
||||
selectedFiles={[contextMenuFile]}
|
||||
/>
|
||||
</Menu>
|
||||
</Popover>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default FilesTableView
|
||||
@@ -2,6 +2,8 @@ import * as icons from '@standardnotes/icons'
|
||||
|
||||
export const IconNameToSvgMapping = {
|
||||
'account-circle': icons.AccountCircleIcon,
|
||||
'arrow-up': icons.ArrowUpIcon,
|
||||
'arrow-down': icons.ArrowDownIcon,
|
||||
'arrow-left': icons.ArrowLeftIcon,
|
||||
'arrow-right': icons.ArrowRightIcon,
|
||||
'arrows-sort-down': icons.ArrowsSortDownIcon,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||
import { FilesController } from '@/Controllers/FilesController'
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
import { useState } from 'react'
|
||||
import { PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
|
||||
import { FileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
|
||||
import { FileContextMenuBackupOption } from '../FileContextMenu/FileContextMenuBackupOption'
|
||||
import Icon from '../Icon/Icon'
|
||||
import HorizontalSeparator from '../Shared/HorizontalSeparator'
|
||||
@@ -24,7 +24,7 @@ const LinkedFileMenuOptions = ({ file, closeMenu, handleFileAction, setIsRenamin
|
||||
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
|
||||
onClick={() => {
|
||||
void handleFileAction({
|
||||
type: PopoverFileItemActionType.PreviewFile,
|
||||
type: FileItemActionType.PreviewFile,
|
||||
payload: {
|
||||
file,
|
||||
otherFiles: [],
|
||||
@@ -41,7 +41,7 @@ const LinkedFileMenuOptions = ({ file, closeMenu, handleFileAction, setIsRenamin
|
||||
className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
|
||||
onClick={() => {
|
||||
handleFileAction({
|
||||
type: PopoverFileItemActionType.ToggleFileProtection,
|
||||
type: FileItemActionType.ToggleFileProtection,
|
||||
payload: { file },
|
||||
callback: (isProtected: boolean) => {
|
||||
setIsFileProtected(isProtected)
|
||||
@@ -60,7 +60,7 @@ const LinkedFileMenuOptions = ({ file, closeMenu, handleFileAction, setIsRenamin
|
||||
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
|
||||
onClick={() => {
|
||||
handleFileAction({
|
||||
type: PopoverFileItemActionType.DownloadFile,
|
||||
type: FileItemActionType.DownloadFile,
|
||||
payload: { file },
|
||||
}).catch(console.error)
|
||||
closeMenu()
|
||||
@@ -83,7 +83,7 @@ const LinkedFileMenuOptions = ({ file, closeMenu, handleFileAction, setIsRenamin
|
||||
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
|
||||
onClick={() => {
|
||||
handleFileAction({
|
||||
type: PopoverFileItemActionType.DeleteFile,
|
||||
type: FileItemActionType.DeleteFile,
|
||||
payload: { file },
|
||||
}).catch(console.error)
|
||||
closeMenu()
|
||||
|
||||
@@ -11,7 +11,6 @@ import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
|
||||
import { ItemLink } from '@/Utils/Items/Search/ItemLink'
|
||||
import { FOCUS_TAGS_INPUT_COMMAND, keyboardStringForShortcut } from '@standardnotes/ui-services'
|
||||
import { useCommandService } from '../CommandProvider'
|
||||
import { useApplication } from '../ApplicationProvider'
|
||||
import { useItemLinks } from '@/Hooks/useItemLinks'
|
||||
|
||||
type Props = {
|
||||
@@ -19,14 +18,11 @@ type Props = {
|
||||
}
|
||||
|
||||
const LinkedItemBubblesContainer = ({ linkingController }: Props) => {
|
||||
const application = useApplication()
|
||||
const activeItem = application.itemControllerGroup.activeItemViewController?.item
|
||||
|
||||
const { toggleAppPane } = useResponsiveAppPane()
|
||||
|
||||
const commandService = useCommandService()
|
||||
|
||||
const { unlinkItemFromSelectedItem: unlinkItem, activateItem } = linkingController
|
||||
const { activeItem, unlinkItemFromSelectedItem: unlinkItem, activateItem } = linkingController
|
||||
|
||||
const { notesLinkedToItem, filesLinkedToItem, tagsLinkedToItem, notesLinkingToItem, filesLinkingToItem } =
|
||||
useItemLinks(activeItem)
|
||||
|
||||
@@ -3,7 +3,6 @@ import { FilesController } from '@/Controllers/FilesController'
|
||||
import { LinkingController } from '@/Controllers/LinkingController'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useRef, useCallback } from 'react'
|
||||
import { useApplication } from '../ApplicationProvider'
|
||||
import RoundIconButton from '../Button/RoundIconButton'
|
||||
import Popover from '../Popover/Popover'
|
||||
import StyledTooltip from '../StyledTooltip/StyledTooltip'
|
||||
@@ -17,10 +16,7 @@ type Props = {
|
||||
}
|
||||
|
||||
const LinkedItemsButton = ({ linkingController, filesController, onClickPreprocessing, featuresController }: Props) => {
|
||||
const application = useApplication()
|
||||
const activeItem = application.itemControllerGroup.activeItemViewController?.item
|
||||
|
||||
const { isLinkingPanelOpen, setIsLinkingPanelOpen } = linkingController
|
||||
const { activeItem, isLinkingPanelOpen, setIsLinkingPanelOpen } = linkingController
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const toggleMenu = useCallback(async () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import DecoratedInput from '../Input/DecoratedInput'
|
||||
import LinkedItemSearchResults from './LinkedItemSearchResults'
|
||||
import { LinkedItemsSectionItem } from './LinkedItemsSectionItem'
|
||||
import { DecryptedItem } from '@standardnotes/snjs'
|
||||
import { useItemLinks } from '@/Hooks/useItemLinks'
|
||||
|
||||
const LinkedItemsPanel = ({
|
||||
linkingController,
|
||||
@@ -27,22 +28,10 @@ const LinkedItemsPanel = ({
|
||||
isOpen: boolean
|
||||
item: DecryptedItem
|
||||
}) => {
|
||||
const {
|
||||
getLinkedTagsForItem,
|
||||
getFilesLinksForItem,
|
||||
getLinkedNotesForItem,
|
||||
getNotesLinkingToItem,
|
||||
linkItems,
|
||||
unlinkItemFromSelectedItem,
|
||||
activateItem,
|
||||
createAndAddNewTag,
|
||||
isEntitledToNoteLinking,
|
||||
} = linkingController
|
||||
const { linkItems, unlinkItems, activateItem, createAndAddNewTag, isEntitledToNoteLinking } = linkingController
|
||||
|
||||
const tagsLinkedToItem = getLinkedTagsForItem(item) || []
|
||||
const { filesLinkedToItem, filesLinkingToItem } = getFilesLinksForItem(item)
|
||||
const notesLinkedToItem = getLinkedNotesForItem(item) || []
|
||||
const notesLinkingToItem = getNotesLinkingToItem(item) || []
|
||||
const { notesLinkedToItem, notesLinkingToItem, filesLinkedToItem, filesLinkingToItem, tagsLinkedToItem } =
|
||||
useItemLinks(item)
|
||||
|
||||
const { entitledToFiles } = featuresController
|
||||
const application = useApplication()
|
||||
@@ -128,7 +117,7 @@ const LinkedItemsPanel = ({
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link.item)}
|
||||
unlinkItem={() => unlinkItems(item, link.item)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
@@ -148,7 +137,7 @@ const LinkedItemsPanel = ({
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link.item)}
|
||||
unlinkItem={() => unlinkItems(item, link.item)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
@@ -172,7 +161,7 @@ const LinkedItemsPanel = ({
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link.item)}
|
||||
unlinkItem={() => unlinkItems(item, link.item)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
@@ -191,7 +180,7 @@ const LinkedItemsPanel = ({
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link.item)}
|
||||
unlinkItem={() => unlinkItems(item, link.item)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
@@ -208,7 +197,7 @@ const LinkedItemsPanel = ({
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link.item)}
|
||||
unlinkItem={() => unlinkItems(item, link.item)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
@@ -227,7 +216,7 @@ const LinkedItemsPanel = ({
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link.item)}
|
||||
unlinkItem={() => unlinkItems(item, link.item)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { FileItem } from '@standardnotes/snjs'
|
||||
import { KeyboardKey } from '@standardnotes/ui-services'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useApplication } from '../ApplicationProvider'
|
||||
import { PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
|
||||
import { FileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
|
||||
import Icon from '../Icon/Icon'
|
||||
import MenuItem from '../Menu/MenuItem'
|
||||
import Popover from '../Popover/Popover'
|
||||
@@ -46,7 +46,7 @@ export const LinkedItemsSectionItem = ({
|
||||
return
|
||||
}
|
||||
await handleFileAction({
|
||||
type: PopoverFileItemActionType.RenameFile,
|
||||
type: FileItemActionType.RenameFile,
|
||||
payload: {
|
||||
file: item,
|
||||
name: name,
|
||||
|
||||
@@ -303,6 +303,7 @@ const PanesSystemComponent = () => {
|
||||
selectionController={viewControllerManager.selectionController}
|
||||
searchOptionsController={viewControllerManager.searchOptionsController}
|
||||
linkingController={viewControllerManager.linkingController}
|
||||
featuresController={viewControllerManager.featuresController}
|
||||
>
|
||||
{showPanelResizers && listRef && (
|
||||
<PanelResizer
|
||||
|
||||
@@ -18,6 +18,8 @@ type ExperimentalFeatureItem = {
|
||||
|
||||
type Props = {
|
||||
application: {
|
||||
setValue: WebApplication['setValue']
|
||||
getValue: WebApplication['getValue']
|
||||
features: WebApplication['features']
|
||||
}
|
||||
}
|
||||
|
||||
38
packages/web/src/javascripts/Components/Table/CommonTypes.ts
Normal file
38
packages/web/src/javascripts/Components/Table/CommonTypes.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { SortableItem } from '@standardnotes/snjs'
|
||||
import { MouseEventHandler, ReactNode } from 'react'
|
||||
|
||||
export type TableSortBy = keyof SortableItem
|
||||
|
||||
export type TableColumn<Data> = {
|
||||
name: string
|
||||
sortBy?: TableSortBy
|
||||
cell: (data: Data) => ReactNode
|
||||
}
|
||||
|
||||
export type TableRow<Data> = {
|
||||
id: string
|
||||
cells: ReactNode[]
|
||||
isSelected: boolean
|
||||
rowData: Data
|
||||
rowActions?: ReactNode
|
||||
}
|
||||
|
||||
export type TableHeader = {
|
||||
name: string
|
||||
isSorting: boolean | undefined
|
||||
sortBy?: TableSortBy
|
||||
sortReversed: boolean | undefined
|
||||
onSortChange: () => void
|
||||
}
|
||||
|
||||
export type Table<Data> = {
|
||||
headers: TableHeader[]
|
||||
rows: TableRow<Data>[]
|
||||
handleRowClick: (id: string) => MouseEventHandler<HTMLTableRowElement>
|
||||
handleRowDoubleClick: (id: string) => MouseEventHandler<HTMLTableRowElement>
|
||||
handleRowContextMenu: (id: string) => MouseEventHandler<HTMLTableRowElement>
|
||||
canSelectRows: boolean
|
||||
selectedRows: string[]
|
||||
selectionActions: ReactNode | undefined
|
||||
showSelectionActions: boolean
|
||||
}
|
||||
82
packages/web/src/javascripts/Components/Table/Table.tsx
Normal file
82
packages/web/src/javascripts/Components/Table/Table.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { classNames } from '@standardnotes/snjs'
|
||||
import Icon from '../Icon/Icon'
|
||||
import { Table } from './CommonTypes'
|
||||
|
||||
function Table<Data>({ table }: { table: Table<Data> }) {
|
||||
return (
|
||||
<div className="block min-h-0 overflow-auto">
|
||||
{table.showSelectionActions && table.selectedRows.length >= 2 && (
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||
<span className="text-info-0 text-sm font-medium">{table.selectedRows.length} selected</span>
|
||||
{table.selectedRows.length > 0 && table.selectionActions}
|
||||
</div>
|
||||
)}
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
{table.headers.map((header, index) => {
|
||||
return (
|
||||
<th
|
||||
className={classNames(
|
||||
'border-b border-border px-3 pt-3 pb-2 text-left text-sm font-medium text-passive-0',
|
||||
header.sortBy && 'cursor-pointer hover:bg-info-backdrop hover:underline',
|
||||
)}
|
||||
onClick={header.onSortChange}
|
||||
key={index.toString()}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{header.name}
|
||||
{header.isSorting && (
|
||||
<Icon
|
||||
type={header.sortReversed ? 'arrow-up' : 'arrow-down'}
|
||||
size="custom"
|
||||
className="h-4.5 w-4.5 text-passive-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border whitespace-nowrap">
|
||||
{table.rows.map((row) => {
|
||||
return (
|
||||
<tr
|
||||
key={row.id}
|
||||
className={classNames(
|
||||
'group relative',
|
||||
row.isSelected && 'bg-info-backdrop',
|
||||
table.canSelectRows && 'cursor-pointer hover:bg-contrast',
|
||||
)}
|
||||
onClick={table.handleRowClick(row.id)}
|
||||
onDoubleClick={table.handleRowDoubleClick(row.id)}
|
||||
onContextMenu={table.handleRowContextMenu(row.id)}
|
||||
>
|
||||
{row.cells.map((cell, index) => {
|
||||
return (
|
||||
<td key={index} className="py-3 px-3">
|
||||
{cell}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
{row.rowActions ? (
|
||||
<div
|
||||
className={classNames(
|
||||
'absolute right-3 top-1/2 -translate-y-1/2',
|
||||
row.isSelected ? '' : 'invisible group-hover:visible',
|
||||
)}
|
||||
>
|
||||
{row.rowActions}
|
||||
</div>
|
||||
) : null}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Table
|
||||
204
packages/web/src/javascripts/Components/Table/useTable.tsx
Normal file
204
packages/web/src/javascripts/Components/Table/useTable.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { MouseEventHandler, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useApplication } from '../ApplicationProvider'
|
||||
import { Table, TableColumn, TableRow, TableSortBy } from './CommonTypes'
|
||||
|
||||
type TableSortOptions =
|
||||
| {
|
||||
sortBy: TableSortBy
|
||||
sortReversed: boolean
|
||||
onSortChange: (sortBy: TableSortBy, reversed: boolean) => void
|
||||
}
|
||||
| {
|
||||
sortBy?: never
|
||||
sortReversed?: never
|
||||
onSortChange?: never
|
||||
}
|
||||
|
||||
type TableSelectionOptions =
|
||||
| {
|
||||
enableRowSelection: boolean
|
||||
enableMultipleRowSelection?: boolean
|
||||
selectedRowIds?: string[]
|
||||
onRowSelectionChange?: (rowIds: string[]) => void
|
||||
selectionActions?: (selected: string[]) => ReactNode
|
||||
showSelectionActions?: boolean
|
||||
}
|
||||
| {
|
||||
enableRowSelection?: never
|
||||
enableMultipleRowSelection?: never
|
||||
selectedRowIds?: never
|
||||
onRowSelectionChange?: never
|
||||
selectionActions?: never
|
||||
showSelectionActions?: never
|
||||
}
|
||||
|
||||
type TableRowOptions<Data> = {
|
||||
getRowId?: (data: Data) => string
|
||||
onRowDoubleClick?: (data: Data) => void
|
||||
onRowContextMenu?: (x: number, y: number, data: Data) => void
|
||||
rowActions?: (data: Data) => ReactNode
|
||||
}
|
||||
|
||||
export type UseTableOptions<Data> = {
|
||||
data: Data[]
|
||||
columns: TableColumn<Data>[]
|
||||
} & TableRowOptions<Data> &
|
||||
TableSortOptions &
|
||||
TableSelectionOptions
|
||||
|
||||
export function useTable<Data>({
|
||||
data,
|
||||
columns,
|
||||
sortBy,
|
||||
sortReversed,
|
||||
onSortChange,
|
||||
getRowId,
|
||||
enableRowSelection,
|
||||
enableMultipleRowSelection,
|
||||
selectedRowIds,
|
||||
onRowSelectionChange,
|
||||
onRowDoubleClick,
|
||||
onRowContextMenu,
|
||||
rowActions,
|
||||
selectionActions,
|
||||
showSelectionActions,
|
||||
}: UseTableOptions<Data>): Table<Data> {
|
||||
const application = useApplication()
|
||||
const [selectedRows, setSelectedRows] = useState<string[]>(selectedRowIds || [])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRowIds) {
|
||||
setSelectedRows(selectedRowIds)
|
||||
}
|
||||
}, [selectedRowIds])
|
||||
|
||||
useEffect(() => {
|
||||
if (onRowSelectionChange) {
|
||||
onRowSelectionChange(selectedRows)
|
||||
}
|
||||
}, [selectedRows, onRowSelectionChange])
|
||||
|
||||
const headers = useMemo(
|
||||
() =>
|
||||
columns.map((column) => {
|
||||
return {
|
||||
name: column.name,
|
||||
isSorting: sortBy && sortBy === column.sortBy,
|
||||
sortBy: column.sortBy,
|
||||
sortReversed: sortReversed,
|
||||
onSortChange: () => {
|
||||
if (!onSortChange || !column.sortBy) {
|
||||
return
|
||||
}
|
||||
onSortChange(column.sortBy, sortBy === column.sortBy ? !sortReversed : false)
|
||||
},
|
||||
}
|
||||
}),
|
||||
[columns, onSortChange, sortBy, sortReversed],
|
||||
)
|
||||
|
||||
const rows: TableRow<Data>[] = useMemo(
|
||||
() =>
|
||||
data.map((rowData, index) => {
|
||||
const cells = columns.map((column) => {
|
||||
return column.cell(rowData)
|
||||
})
|
||||
const id = getRowId ? getRowId(rowData) : index.toString()
|
||||
const row: TableRow<Data> = {
|
||||
id,
|
||||
isSelected: enableRowSelection ? selectedRows.includes(id) : false,
|
||||
cells,
|
||||
rowData,
|
||||
rowActions: rowActions ? rowActions(rowData) : undefined,
|
||||
}
|
||||
return row
|
||||
}),
|
||||
[columns, data, enableRowSelection, getRowId, rowActions, selectedRows],
|
||||
)
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(id: string) => {
|
||||
const handler: MouseEventHandler<HTMLTableRowElement> = (event) => {
|
||||
if (!enableRowSelection) {
|
||||
return
|
||||
}
|
||||
const isCmdOrCtrlPressed = application.keyboardService.isMac ? event.metaKey : event.ctrlKey
|
||||
if (isCmdOrCtrlPressed && enableMultipleRowSelection) {
|
||||
setSelectedRows((prev) => (prev.includes(id) ? prev.filter((rowId) => rowId !== id) : [...prev, id]))
|
||||
} else if (event.shiftKey && enableMultipleRowSelection) {
|
||||
const lastSelectedIndex = rows.findIndex((row) => row.id === selectedRows[selectedRows.length - 1])
|
||||
const currentIndex = rows.findIndex((row) => row.id === id)
|
||||
const start = Math.min(lastSelectedIndex, currentIndex)
|
||||
const end = Math.max(lastSelectedIndex, currentIndex)
|
||||
const newSelectedRows = rows.slice(start, end + 1).map((row) => row.id)
|
||||
setSelectedRows(newSelectedRows)
|
||||
} else {
|
||||
setSelectedRows([id])
|
||||
}
|
||||
}
|
||||
return handler
|
||||
},
|
||||
[application.keyboardService.isMac, enableMultipleRowSelection, enableRowSelection, rows, selectedRows],
|
||||
)
|
||||
|
||||
const handleRowDoubleClick = useCallback(
|
||||
(id: string) => {
|
||||
const handler: MouseEventHandler<HTMLTableRowElement> = () => {
|
||||
if (!onRowDoubleClick) {
|
||||
return
|
||||
}
|
||||
const rowData = rows.find((row) => row.id === id)?.rowData
|
||||
if (rowData) {
|
||||
onRowDoubleClick(rowData)
|
||||
}
|
||||
}
|
||||
return handler
|
||||
},
|
||||
[onRowDoubleClick, rows],
|
||||
)
|
||||
|
||||
const handleRowContextMenu = useCallback(
|
||||
(id: string) => {
|
||||
const handler: MouseEventHandler<HTMLTableRowElement> = (event) => {
|
||||
if (!onRowContextMenu) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const rowData = rows.find((row) => row.id === id)?.rowData
|
||||
if (rowData) {
|
||||
setSelectedRows([id])
|
||||
onRowContextMenu(event.clientX, event.clientY, rowData)
|
||||
}
|
||||
}
|
||||
return handler
|
||||
},
|
||||
[onRowContextMenu, rows],
|
||||
)
|
||||
|
||||
const table: Table<Data> = useMemo(
|
||||
() => ({
|
||||
headers,
|
||||
rows,
|
||||
handleRowClick,
|
||||
handleRowDoubleClick,
|
||||
handleRowContextMenu,
|
||||
selectedRows,
|
||||
canSelectRows: enableRowSelection || false,
|
||||
selectionActions: selectionActions ? selectionActions(selectedRows) : undefined,
|
||||
showSelectionActions: showSelectionActions || false,
|
||||
}),
|
||||
[
|
||||
enableRowSelection,
|
||||
handleRowClick,
|
||||
handleRowContextMenu,
|
||||
handleRowDoubleClick,
|
||||
headers,
|
||||
rows,
|
||||
selectedRows,
|
||||
selectionActions,
|
||||
showSelectionActions,
|
||||
],
|
||||
)
|
||||
|
||||
return table
|
||||
}
|
||||
@@ -4,10 +4,7 @@ import {
|
||||
OnChunkCallbackNoProgress,
|
||||
} from '@standardnotes/files'
|
||||
import { FilePreviewModalController } from './FilePreviewModalController'
|
||||
import {
|
||||
PopoverFileItemAction,
|
||||
PopoverFileItemActionType,
|
||||
} from '@/Components/AttachedFilesPopover/PopoverFileItemAction'
|
||||
import { FileItemAction, FileItemActionType } from '@/Components/AttachedFilesPopover/PopoverFileItemAction'
|
||||
import { BYTES_IN_ONE_MEGABYTE } from '@/Constants/Constants'
|
||||
import { confirmDialog } from '@standardnotes/ui-services'
|
||||
import { Strings, StringUtils } from '@/Constants/Strings'
|
||||
@@ -34,8 +31,8 @@ import { AbstractViewController } from './Abstract/AbstractViewController'
|
||||
import { NotesController } from './NotesController/NotesController'
|
||||
import { downloadOrShareBlobBasedOnPlatform } from '@/Utils/DownloadOrShareBasedOnPlatform'
|
||||
|
||||
const UnprotectedFileActions = [PopoverFileItemActionType.ToggleFileProtection]
|
||||
const NonMutatingFileActions = [PopoverFileItemActionType.DownloadFile, PopoverFileItemActionType.PreviewFile]
|
||||
const UnprotectedFileActions = [FileItemActionType.ToggleFileProtection]
|
||||
const NonMutatingFileActions = [FileItemActionType.DownloadFile, FileItemActionType.PreviewFile]
|
||||
|
||||
type FileContextMenuLocation = { x: number; y: number }
|
||||
|
||||
@@ -195,7 +192,7 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
|
||||
}
|
||||
|
||||
handleFileAction = async (
|
||||
action: PopoverFileItemAction,
|
||||
action: FileItemAction,
|
||||
): Promise<{
|
||||
didHandleAction: boolean
|
||||
}> => {
|
||||
@@ -215,27 +212,27 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case PopoverFileItemActionType.AttachFileToNote:
|
||||
case FileItemActionType.AttachFileToNote:
|
||||
await this.attachFileToSelectedNote(file)
|
||||
break
|
||||
case PopoverFileItemActionType.DetachFileToNote:
|
||||
case FileItemActionType.DetachFileToNote:
|
||||
await this.detachFileFromNote(file)
|
||||
break
|
||||
case PopoverFileItemActionType.DeleteFile:
|
||||
case FileItemActionType.DeleteFile:
|
||||
await this.deleteFile(file)
|
||||
break
|
||||
case PopoverFileItemActionType.DownloadFile:
|
||||
case FileItemActionType.DownloadFile:
|
||||
await this.downloadFile(file)
|
||||
break
|
||||
case PopoverFileItemActionType.ToggleFileProtection: {
|
||||
case FileItemActionType.ToggleFileProtection: {
|
||||
const isProtected = await this.toggleFileProtection(file)
|
||||
action.callback(isProtected)
|
||||
break
|
||||
}
|
||||
case PopoverFileItemActionType.RenameFile:
|
||||
case FileItemActionType.RenameFile:
|
||||
await this.renameFile(file, action.payload.name)
|
||||
break
|
||||
case PopoverFileItemActionType.PreviewFile:
|
||||
case FileItemActionType.PreviewFile:
|
||||
this.filePreviewModalController.activate(file, action.payload.otherFiles)
|
||||
break
|
||||
}
|
||||
@@ -426,7 +423,7 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
|
||||
label: 'Open',
|
||||
handler: (toastId) => {
|
||||
void this.handleFileAction({
|
||||
type: PopoverFileItemActionType.PreviewFile,
|
||||
type: FileItemActionType.PreviewFile,
|
||||
payload: { file: uploadedFile },
|
||||
})
|
||||
dismissToast(toastId)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { PopoverFileItemActionType } from '@/Components/AttachedFilesPopover/PopoverFileItemAction'
|
||||
import { FileItemActionType } from '@/Components/AttachedFilesPopover/PopoverFileItemAction'
|
||||
import { NoteViewController } from '@/Components/NoteView/Controller/NoteViewController'
|
||||
import { AppPaneId } from '@/Components/Panes/AppPaneMetadata'
|
||||
import { PrefDefaults } from '@/Constants/PrefDefaults'
|
||||
@@ -153,7 +153,7 @@ export class LinkingController extends AbstractViewController {
|
||||
}
|
||||
} else if (item instanceof FileItem) {
|
||||
await this.filesController.handleFileAction({
|
||||
type: PopoverFileItemActionType.PreviewFile,
|
||||
type: FileItemActionType.PreviewFile,
|
||||
payload: {
|
||||
file: item,
|
||||
otherFiles: [],
|
||||
@@ -164,6 +164,12 @@ export class LinkingController extends AbstractViewController {
|
||||
return undefined
|
||||
}
|
||||
|
||||
unlinkItems = async (item: LinkableItem, itemToUnlink: LinkableItem) => {
|
||||
await this.application.items.unlinkItems(item, itemToUnlink)
|
||||
|
||||
void this.application.sync.sync()
|
||||
}
|
||||
|
||||
unlinkItemFromSelectedItem = async (itemToUnlink: LinkableItem) => {
|
||||
const selectedItem = this.selectionController.firstSelectedItem
|
||||
|
||||
@@ -171,9 +177,7 @@ export class LinkingController extends AbstractViewController {
|
||||
return
|
||||
}
|
||||
|
||||
await this.application.items.unlinkItems(selectedItem, itemToUnlink)
|
||||
|
||||
void this.application.sync.sync()
|
||||
void this.unlinkItems(selectedItem, itemToUnlink)
|
||||
}
|
||||
|
||||
ensureActiveItemIsInserted = async () => {
|
||||
|
||||
@@ -2,10 +2,12 @@ import { isDev } from '@/Utils'
|
||||
|
||||
export enum FeatureTrunkName {
|
||||
Super,
|
||||
FilesTableView,
|
||||
}
|
||||
|
||||
const FeatureTrunkStatus: Record<FeatureTrunkName, boolean> = {
|
||||
[FeatureTrunkName.Super]: isDev && true,
|
||||
[FeatureTrunkName.FilesTableView]: isDev && true,
|
||||
}
|
||||
|
||||
export function featureTrunkEnabled(trunk: FeatureTrunkName): boolean {
|
||||
|
||||
Reference in New Issue
Block a user