diff --git a/packages/web/src/javascripts/Components/AttachedFilesPopover/PopoverFileItemAction.tsx b/packages/web/src/javascripts/Components/AttachedFilesPopover/PopoverFileItemAction.tsx index 890197e59..f2a4a94e7 100644 --- a/packages/web/src/javascripts/Components/AttachedFilesPopover/PopoverFileItemAction.tsx +++ b/packages/web/src/javascripts/Components/AttachedFilesPopover/PopoverFileItemAction.tsx @@ -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[] diff --git a/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx b/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx index 2d9b92669..b2cf340fb 100644 --- a/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx @@ -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( selectionController, searchOptionsController, linkingController, + featuresController, className, id, children, @@ -280,6 +285,9 @@ const ContentListView = forwardRef( } }, [selectedUuids, innerRef, isCurrentNoteTemplate, renderedItems, panes]) + const isFilesTableViewEnabled = featureTrunkEnabled(FeatureTrunkName.FilesTableView) + const shouldShowFilesTableView = isFilesTableViewEnabled && selectedTag?.uuid === SystemViewId.Files + return (
( addButtonLabel={addButtonLabel} addNewItem={addNewItem} isFilesSmartView={isFilesSmartView} + isFilesTableViewEnabled={isFilesTableViewEnabled} optionsSubtitle={optionsSubtitle} selectedTag={selectedTag} filesController={filesController} + itemListController={itemListController} /> )} - + {!isFilesTableViewEnabled && ( + + )} ( {!dailyMode && completedFullSync && !renderedItems.length ? (

No items.

) : null} - {!dailyMode && !completedFullSync && !renderedItems.length ? (

Loading...

) : null} - {!dailyMode && renderedItems.length ? ( - <> + shouldShowFilesTableView ? ( + + ) : ( ( notesController={notesController} selectionController={selectionController} /> - + ) ) : null}
{children} diff --git a/packages/web/src/javascripts/Components/ContentListView/Header/AddItemMenuButton.tsx b/packages/web/src/javascripts/Components/ContentListView/Header/AddItemMenuButton.tsx index 669132c88..b2f9a2583 100644 --- a/packages/web/src/javascripts/Components/ContentListView/Header/AddItemMenuButton.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/Header/AddItemMenuButton.tsx @@ -44,12 +44,8 @@ const AddItemMenuButton = ({ <> + { + setContextMenuVisible(false) + }} + side="bottom" + align="start" + className="py-2" + > + + { + setContextMenuVisible(false) + }} + filesController={filesController} + shouldShowRenameOption={false} + shouldShowAttachOption={false} + selectedFiles={files} + /> + + + + ) +} + +const FileLinksCell = ({ + file, + filesController, + linkingController, + featuresController, +}: { + file: FileItem + filesController: FilesController + linkingController: LinkingController + featuresController: FeaturesController +}) => { + const [contextMenuVisible, setContextMenuVisible] = useState(false) + const anchorElementRef = useRef(null) + + return ( + <> + + { + setContextMenuVisible(false) + }} + side="bottom" + align="start" + className="py-2" + > + + + + ) +} + +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( + 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(undefined) + const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | undefined>(undefined) + + const columnDefs: TableColumn[] = useMemo( + () => [ + { + name: 'Name', + sortBy: 'title', + cell: (file) => { + return ( +
+ {getFileIconComponent(getIconForFileType(file.mimeType), 'w-6 h-6 flex-shrink-0')} + {file.title} + {file.protected && ( + + + + )} +
+ ) + }, + }, + { + 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 ( +
+ { + void application.items.unlinkItems(file, itemToUnlink) + }} + isBidirectional={false} + /> + {links.length > 1 && and {links.length - 1} more...} +
+ ) + }, + }, + ], + [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 ( +
+ {links.length > 0 && ( + + )} + +
+ ) + }, + selectionActions: (fileIds) => ( + fileIds.includes(file.uuid))} filesController={filesController} /> + ), + showSelectionActions: true, + }) + + return ( + <> + + {contextMenuPosition && contextMenuFile && ( + { + setContextMenuPosition(undefined) + setContextMenuFile(undefined) + }} + side="bottom" + align="start" + className="py-2" + > + + { + setContextMenuPosition(undefined) + setContextMenuFile(undefined) + }} + filesController={filesController} + shouldShowRenameOption={false} + shouldShowAttachOption={false} + selectedFiles={[contextMenuFile]} + /> + + + )} + + ) +} +export default FilesTableView diff --git a/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx b/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx index f17b3a1be..50ae57d8a 100644 --- a/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx +++ b/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx @@ -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, diff --git a/packages/web/src/javascripts/Components/LinkedItems/LinkedFileMenuOptions.tsx b/packages/web/src/javascripts/Components/LinkedItems/LinkedFileMenuOptions.tsx index afa1fb538..1625f0940 100644 --- a/packages/web/src/javascripts/Components/LinkedItems/LinkedFileMenuOptions.tsx +++ b/packages/web/src/javascripts/Components/LinkedItems/LinkedFileMenuOptions.tsx @@ -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() diff --git a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubblesContainer.tsx b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubblesContainer.tsx index 464a73468..cc54fb2a4 100644 --- a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubblesContainer.tsx +++ b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubblesContainer.tsx @@ -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) diff --git a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsButton.tsx b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsButton.tsx index ef6cfcbb2..ec38b9882 100644 --- a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsButton.tsx +++ b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsButton.tsx @@ -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(null) const toggleMenu = useCallback(async () => { diff --git a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsPanel.tsx b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsPanel.tsx index 154727b98..317de8e0d 100644 --- a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsPanel.tsx +++ b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsPanel.tsx @@ -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} /> diff --git a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsSectionItem.tsx b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsSectionItem.tsx index 19370acfe..3b875a765 100644 --- a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsSectionItem.tsx +++ b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsSectionItem.tsx @@ -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, diff --git a/packages/web/src/javascripts/Components/Panes/PanesSystemComponent.tsx b/packages/web/src/javascripts/Components/Panes/PanesSystemComponent.tsx index 4fb0b5891..d780e79f4 100644 --- a/packages/web/src/javascripts/Components/Panes/PanesSystemComponent.tsx +++ b/packages/web/src/javascripts/Components/Panes/PanesSystemComponent.tsx @@ -303,6 +303,7 @@ const PanesSystemComponent = () => { selectionController={viewControllerManager.selectionController} searchOptionsController={viewControllerManager.searchOptionsController} linkingController={viewControllerManager.linkingController} + featuresController={viewControllerManager.featuresController} > {showPanelResizers && listRef && ( = { + name: string + sortBy?: TableSortBy + cell: (data: Data) => ReactNode +} + +export type TableRow = { + 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 = { + headers: TableHeader[] + rows: TableRow[] + handleRowClick: (id: string) => MouseEventHandler + handleRowDoubleClick: (id: string) => MouseEventHandler + handleRowContextMenu: (id: string) => MouseEventHandler + canSelectRows: boolean + selectedRows: string[] + selectionActions: ReactNode | undefined + showSelectionActions: boolean +} diff --git a/packages/web/src/javascripts/Components/Table/Table.tsx b/packages/web/src/javascripts/Components/Table/Table.tsx new file mode 100644 index 000000000..65caaa0be --- /dev/null +++ b/packages/web/src/javascripts/Components/Table/Table.tsx @@ -0,0 +1,82 @@ +import { classNames } from '@standardnotes/snjs' +import Icon from '../Icon/Icon' +import { Table } from './CommonTypes' + +function Table({ table }: { table: Table }) { + return ( +
+ {table.showSelectionActions && table.selectedRows.length >= 2 && ( +
+ {table.selectedRows.length} selected + {table.selectedRows.length > 0 && table.selectionActions} +
+ )} +
+ + + {table.headers.map((header, index) => { + return ( + + ) + })} + + + + {table.rows.map((row) => { + return ( + + {row.cells.map((cell, index) => { + return ( + + ) + })} + {row.rowActions ? ( +
+ {row.rowActions} +
+ ) : null} + + ) + })} + +
+
+ {header.name} + {header.isSorting && ( + + )} +
+
+ {cell} +
+
+ ) +} + +export default Table diff --git a/packages/web/src/javascripts/Components/Table/useTable.tsx b/packages/web/src/javascripts/Components/Table/useTable.tsx new file mode 100644 index 000000000..d7d556c56 --- /dev/null +++ b/packages/web/src/javascripts/Components/Table/useTable.tsx @@ -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 = { + 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[] + columns: TableColumn[] +} & TableRowOptions & + TableSortOptions & + TableSelectionOptions + +export function useTable({ + data, + columns, + sortBy, + sortReversed, + onSortChange, + getRowId, + enableRowSelection, + enableMultipleRowSelection, + selectedRowIds, + onRowSelectionChange, + onRowDoubleClick, + onRowContextMenu, + rowActions, + selectionActions, + showSelectionActions, +}: UseTableOptions): Table { + const application = useApplication() + const [selectedRows, setSelectedRows] = useState(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[] = useMemo( + () => + data.map((rowData, index) => { + const cells = columns.map((column) => { + return column.cell(rowData) + }) + const id = getRowId ? getRowId(rowData) : index.toString() + const row: TableRow = { + 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 = (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 = () => { + 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 = (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 = 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 +} diff --git a/packages/web/src/javascripts/Controllers/FilesController.ts b/packages/web/src/javascripts/Controllers/FilesController.ts index 985f92282..402e69c90 100644 --- a/packages/web/src/javascripts/Controllers/FilesController.ts +++ b/packages/web/src/javascripts/Controllers/FilesController.ts @@ -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 => { @@ -215,27 +212,27 @@ export class FilesController extends AbstractViewController { void this.handleFileAction({ - type: PopoverFileItemActionType.PreviewFile, + type: FileItemActionType.PreviewFile, payload: { file: uploadedFile }, }) dismissToast(toastId) diff --git a/packages/web/src/javascripts/Controllers/LinkingController.tsx b/packages/web/src/javascripts/Controllers/LinkingController.tsx index b76f92987..990052764 100644 --- a/packages/web/src/javascripts/Controllers/LinkingController.tsx +++ b/packages/web/src/javascripts/Controllers/LinkingController.tsx @@ -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 () => { diff --git a/packages/web/src/javascripts/FeatureTrunk.ts b/packages/web/src/javascripts/FeatureTrunk.ts index 512532b42..9bba530e2 100644 --- a/packages/web/src/javascripts/FeatureTrunk.ts +++ b/packages/web/src/javascripts/FeatureTrunk.ts @@ -2,10 +2,12 @@ import { isDev } from '@/Utils' export enum FeatureTrunkName { Super, + FilesTableView, } const FeatureTrunkStatus: Record = { [FeatureTrunkName.Super]: isDev && true, + [FeatureTrunkName.FilesTableView]: isDev && true, } export function featureTrunkEnabled(trunk: FeatureTrunkName): boolean {