From 82ae18b4b9a784d7012b9fd8cb922d1a7ede2032 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Thu, 6 Nov 2025 01:46:33 +0100 Subject: [PATCH] sorting --- frontend/src/app/AppLayout.jsx | 217 +++++++++++++++++++--- frontend/src/documents/DocumentsPanel.jsx | 153 ++++++++++++++- frontend/src/sidebar/Sidebar.jsx | 16 ++ frontend/src/styles.css | 91 ++++++++- frontend/src/ui/icons.js | 40 ++++ 5 files changed, 489 insertions(+), 28 deletions(-) diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx index 43b3556..2b28eff 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.jsx @@ -27,6 +27,10 @@ import { isTagTransferEvent } from '../documents/tagTransfer'; const ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early const DEFAULT_FOLDER_NAME = 'Documents'; +const DEFAULT_SORT_FIELD = 'title'; +const DEFAULT_SORT_DIRECTION = 'asc'; +const SORT_FIELD_VALUES = ['title', 'issued_at', 'created_at', 'updated_at']; +const TAG_FILTER_UNTAGGED = '__UNTAGGED__'; const ROW_KEY_SEPARATOR = ':'; const DOCUMENT_ROW_PREFIX = 'document'; @@ -204,6 +208,46 @@ const AppLayout = () => { return 'list'; } }); + const [documentsSortField, setDocumentsSortField] = useState(() => { + if (typeof window === 'undefined') { + return DEFAULT_SORT_FIELD; + } + try { + const stored = window.sessionStorage.getItem('papercrate_sort_field'); + return SORT_FIELD_VALUES.includes(stored) ? stored : DEFAULT_SORT_FIELD; + } catch (error) { + console.warn('[sort] failed to read stored sort field', error); + return DEFAULT_SORT_FIELD; + } + }); + const [documentsSortDirection, setDocumentsSortDirection] = useState(() => { + if (typeof window === 'undefined') { + return DEFAULT_SORT_DIRECTION; + } + try { + const stored = window.sessionStorage.getItem('papercrate_sort_direction'); + return stored === 'desc' || stored === 'asc' ? stored : DEFAULT_SORT_DIRECTION; + } catch (error) { + console.warn('[sort] failed to read stored sort direction', error); + return DEFAULT_SORT_DIRECTION; + } + }); + const documentsSortFieldRef = useRef(DEFAULT_SORT_FIELD); + const documentsSortDirectionRef = useRef(DEFAULT_SORT_DIRECTION); + const [searchIncludeDescendants, setSearchIncludeDescendants] = useState(() => { + if (typeof window === 'undefined') { + return true; + } + try { + const stored = window.sessionStorage.getItem('papercrate_include_descendants'); + if (stored === 'true') return true; + if (stored === 'false') return false; + } catch (error) { + console.warn('[search] failed to read include_descendants preference', error); + } + return true; + }); + const sortRefreshReadyRef = useRef(false); const [deskHelpOpen, setDeskHelpOpen] = useState(false); const lastNonDeskViewRef = useRef(documentsViewMode === 'desk' ? 'list' : documentsViewMode); @@ -218,6 +262,42 @@ const AppLayout = () => { setDeskHelpOpen(false); } }, [documentsViewMode, deskHelpOpen]); + useEffect(() => { + documentsSortFieldRef.current = documentsSortField; + if (typeof window !== 'undefined') { + try { + window.sessionStorage.setItem('papercrate_sort_field', documentsSortField); + } catch (error) { + console.warn('[sort] failed to persist sort field', error); + } + } + }, [documentsSortField, documentsSortFieldRef]); + + useEffect(() => { + documentsSortDirectionRef.current = documentsSortDirection; + if (typeof window !== 'undefined') { + try { + window.sessionStorage.setItem('papercrate_sort_direction', documentsSortDirection); + } catch (error) { + console.warn('[sort] failed to persist sort direction', error); + } + } + }, [documentsSortDirection, documentsSortDirectionRef]); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + try { + window.sessionStorage.setItem( + 'papercrate_include_descendants', + searchIncludeDescendants ? 'true' : 'false', + ); + } catch (error) { + console.warn('[search] failed to persist include_descendants preference', error); + } + }, [searchIncludeDescendants]); + const tokenRef = useRef(token); const refreshPromiseRef = useRef(null); const breadcrumbFetchRef = useRef(new Set()); @@ -281,11 +361,16 @@ const AppLayout = () => { ); const toggleTagFilter = useCallback((tagId) => { if (!tagId) return; - setActiveTagFilters((previous) => - previous.includes(tagId) - ? previous.filter((id) => id !== tagId) - : previous.concat([tagId]), - ); + setActiveTagFilters((previous) => { + if (tagId === TAG_FILTER_UNTAGGED) { + return previous.includes(TAG_FILTER_UNTAGGED) ? [] : [TAG_FILTER_UNTAGGED]; + } + const sanitized = previous.filter((id) => id !== TAG_FILTER_UNTAGGED); + if (sanitized.includes(tagId)) { + return sanitized.filter((id) => id !== tagId); + } + return sanitized.concat([tagId]); + }); }, []); const toggleCorrespondentFilter = useCallback((correspondentId) => { @@ -297,6 +382,19 @@ const AppLayout = () => { }); }, []); + const handleDocumentsSortFieldChange = useCallback((field) => { + const nextField = SORT_FIELD_VALUES.includes(field) ? field : DEFAULT_SORT_FIELD; + setDocumentsSortField((previous) => (previous === nextField ? previous : nextField)); + }, []); + + const handleDocumentsSortDirectionToggle = useCallback(() => { + setDocumentsSortDirection((previous) => (previous === 'asc' ? 'desc' : 'asc')); + }, []); + + const toggleSearchIncludeDescendants = useCallback(() => { + setSearchIncludeDescendants((previous) => !previous); + }, []); + const initialRefreshAttemptedRef = useRef(Boolean(token)); useEffect(() => { @@ -312,6 +410,7 @@ const AppLayout = () => { setActiveTagFilters([]); setActiveCorrespondentFilters([]); setSearchLoading(false); + setSearchIncludeDescendants(true); }, []); const handleSearchChange = useCallback((value) => { @@ -982,13 +1081,32 @@ const AppLayout = () => { const ensureFolderData = useCallback( async ( folderId, - { force = false, includeDocuments = true, prefetchDepth = 0 } = {}, + { + force = false, + includeDocuments = true, + prefetchDepth = 0, + sortField: overrideSortField, + sortDirection: overrideSortDirection, + } = {}, ) => { const requestTenantId = tenantIdRef.current; const cached = folderContents.get(folderId); + const cachedSortField = cached?.__sortField ?? DEFAULT_SORT_FIELD; + const cachedSortDirection = cached?.__sortDirection ?? DEFAULT_SORT_DIRECTION; + const sortField = includeDocuments + ? overrideSortField || documentsSortFieldRef.current || DEFAULT_SORT_FIELD + : null; + const sortDirection = includeDocuments + ? overrideSortDirection || documentsSortDirectionRef.current || DEFAULT_SORT_DIRECTION + : null; + const cachedSortMatches = + !includeDocuments + || !cached + || (cachedSortField === sortField && cachedSortDirection === sortDirection); + if (!force && cached) { const includesDocuments = Boolean(cached.__includesDocuments); - if (!includeDocuments || includesDocuments) { + if (!includeDocuments || (includesDocuments && cachedSortMatches)) { if (prefetchDepth > 0) { const subfolders = Array.isArray(cached.subfolders) ? cached.subfolders : []; await Promise.allSettled( @@ -1006,18 +1124,28 @@ const AppLayout = () => { } const path = folderId === 'root' ? 'root' : folderId; - const params = includeDocuments - ? undefined - : { include_documents: false }; - const { data } = await api.get(`/folders/${path}/contents`, { - params, - }); + const params = {}; + if (!includeDocuments) { + params.include_documents = false; + } else { + params.sort = sortField; + params.dir = sortDirection; + } + const requestConfig = Object.keys(params).length ? { params } : {}; + const { data } = await api.get(`/folders/${path}/contents`, requestConfig); const hydrated = assetManager.hydrateFolderContents(data); const childFolders = Array.isArray(data.subfolders) ? data.subfolders : []; const childIds = childFolders.map((child) => child.id); + const enriched = { + ...hydrated, + __includesDocuments: includeDocuments, + __sortField: includeDocuments ? sortField : cachedSortField, + __sortDirection: includeDocuments ? sortDirection : cachedSortDirection, + }; + if (tenantIdRef.current !== requestTenantId) { - return { ...hydrated, __includesDocuments: includeDocuments }; + return enriched; } setFolderNodes((prev) => { @@ -1089,11 +1217,6 @@ const AppLayout = () => { ); } - const enriched = { - ...hydrated, - __includesDocuments: includeDocuments, - }; - if (includeDocuments) { setFolderContents((prev) => { if (tenantIdRef.current !== requestTenantId) { @@ -1118,6 +1241,8 @@ const AppLayout = () => { ? existingEntry.documents : hydrated.documents, __includesDocuments: existingEntry.__includesDocuments || false, + __sortField: existingEntry.__sortField ?? enriched.__sortField, + __sortDirection: existingEntry.__sortDirection ?? enriched.__sortDirection, }); } else { next.set(folderId, enriched); @@ -1128,7 +1253,7 @@ const AppLayout = () => { return enriched; }, - [assetManager, folderContents], + [assetManager, documentsSortDirectionRef, documentsSortFieldRef, folderContents], ); const isInvalidFolderDrop = useCallback( @@ -1461,6 +1586,22 @@ const AppLayout = () => { } }, [selectedFolder, ensureFolderData, applySelectedFolder, notifyApiError]); + useEffect(() => { + if (!sortRefreshReadyRef.current) { + sortRefreshReadyRef.current = true; + return; + } + if (!isFilterActive && token) { + refreshCurrentFolder(); + } + }, [ + documentsSortField, + documentsSortDirection, + isFilterActive, + refreshCurrentFolder, + token, + ]); + const handleDocumentCorrespondentAttach = useCallback( async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => { if (!documentId || !correspondentId) { @@ -3719,7 +3860,16 @@ const AppLayout = () => { params.query = trimmedQuery; } if (activeTagFilters.length) { - params.tags = activeTagFilters.join(','); + const onlyUntagged = activeTagFilters.length === 1 + && activeTagFilters[0] === TAG_FILTER_UNTAGGED; + if (onlyUntagged) { + params.tags = 'none'; + } else { + const tagIds = activeTagFilters.filter((id) => id !== TAG_FILTER_UNTAGGED); + if (tagIds.length) { + params.tags = tagIds.join(','); + } + } } if (activeCorrespondentFilters.length) { params.correspondents = activeCorrespondentFilters.join(','); @@ -3728,6 +3878,15 @@ const AppLayout = () => { if (folderIdentifier) { params.folder_id = folderIdentifier; } + if (!searchIncludeDescendants) { + params.include_descendants = false; + } + if (documentsSortField) { + params.sort = documentsSortField; + } + if (documentsSortDirection) { + params.dir = documentsSortDirection; + } const { data } = await api.get('/documents', { params }); if (cancelled) return; @@ -3803,6 +3962,9 @@ const AppLayout = () => { searchQuery, activeTagFilters, activeCorrespondentFilters, + searchIncludeDescendants, + documentsSortField, + documentsSortDirection, selectedFolder, notifyApiError, assetManager, @@ -4561,6 +4723,12 @@ const AppLayout = () => { onCorrespondentClick: toggleCorrespondentFilter, onDocumentTagDrop: handleDocumentTagDrop, viewMode: documentsViewMode, + sortField: documentsSortField, + sortDirection: documentsSortDirection, + onSortFieldChange: handleDocumentsSortFieldChange, + onSortDirectionToggle: handleDocumentsSortDirectionToggle, + searchIncludeDescendants, + onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants, onViewModeChange: handleDocumentsViewModeChange, onClearSelection: clearDocumentSelection, onDeleteSelection: handleDeleteSelection, @@ -4586,6 +4754,8 @@ const AppLayout = () => { currentSubfolders, documents, documentsViewMode, + documentsSortField, + documentsSortDirection, draggedDocumentIds, draggedFolderId, focusedRowKey, @@ -4595,6 +4765,8 @@ const AppLayout = () => { handleDocumentTagDrop, handleDocumentTitleUpdate, handleDocumentsViewModeChange, + handleDocumentsSortFieldChange, + handleDocumentsSortDirectionToggle, handleFolderDragEnd, handleFolderDragStart, handleFolderRename, @@ -4607,6 +4779,7 @@ const AppLayout = () => { refreshCurrentFolder, searchLoading, searchResults, + searchIncludeDescendants, selectFolder, selectedDocumentIds, selectedEntries, @@ -4627,6 +4800,7 @@ const AppLayout = () => { handleBulkSelectionReanalyze, folderOptions, moveDocumentsToFolder, + toggleSearchIncludeDescendants, ], ); @@ -4647,6 +4821,7 @@ const AppLayout = () => { onCreateFolder: handlePromptCreateFolder, creatingFolder, tags, + untaggedFilterId: TAG_FILTER_UNTAGGED, activeTagIds: activeTagFilters, onToggleTagFilter: toggleTagFilter, onCreateTag: (label) => handleTagCreate({ label }), diff --git a/frontend/src/documents/DocumentsPanel.jsx b/frontend/src/documents/DocumentsPanel.jsx index dc3965c..9e1d759 100644 --- a/frontend/src/documents/DocumentsPanel.jsx +++ b/frontend/src/documents/DocumentsPanel.jsx @@ -6,7 +6,12 @@ import { RefreshIcon, MinusVerticalIcon, InfoIcon, + FoldersIcon, + FoldersOffIcon, + SortAscendingLettersIcon, + SortDescendingLettersIcon, } from '../ui/icons'; +import QuickAddMenu from '../ui/QuickAddMenu'; import BreadcrumbTrail from '../ui/BreadcrumbTrail'; import createWorkspaceSurfaceConfig from './workspaceHeader'; import DetailPanel from '../detail/DetailPanel'; @@ -25,6 +30,19 @@ const EntryType = { document: 'document', }; +const SORT_OPTIONS = [ + { value: 'title', label: 'Title' }, + { value: 'issued_at', label: 'Issued date' }, + { value: 'created_at', label: 'Added' }, + { value: 'updated_at', label: 'Updated date' }, +]; + +const SORT_LABEL_LOOKUP = SORT_OPTIONS.reduce((accumulator, option) => { + const next = accumulator; + next[option.value] = option.label; + return next; +}, {}); + const DocumentsPanel = ({ currentFolderName, breadcrumbs, @@ -545,7 +563,6 @@ const DocumentsPanel = ({ const showDefaultEmptyState = !showingSearchResults && !isFilterActive && entries.length === 0; const showListSearchEmptyState = showingSearchResults && !hasDocumentEntries && !isGridView && !isSearchLoading; const showGridSearchEmptyState = isGridView && showingSearchResults && !hasDocumentEntries && !isSearchLoading; - const showSearchHint = showingSearchResults && rows.length > 0; const breadcrumbEntries = useMemo(() => (Array.isArray(breadcrumbs) ? breadcrumbs.filter(Boolean) : []), [breadcrumbs]); const trailEntries = useMemo(() => { if (!breadcrumbEntries.length) { @@ -724,11 +741,6 @@ const DocumentsPanel = ({ /> )} - {showSearchHint && ( -
- Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders. -
- )} )} @@ -743,16 +755,115 @@ const DocumentsPanel = ({ export default DocumentsPanel; +const SortFieldQuickMenu = ({ sortField, onChange }) => { + const currentOption = useMemo( + () => SORT_OPTIONS.find((option) => option.value === sortField) || SORT_OPTIONS[0], + [sortField], + ); + + const options = useMemo( + () => SORT_OPTIONS.map((option) => ({ id: option.value, label: option.label })), + [], + ); + + const handleSelect = useCallback( + (value, option) => { + if (typeof onChange !== 'function') { + return; + } + const nextValue = option?.id || option?.original?.id || value; + if (nextValue) { + onChange(nextValue); + } + }, + [onChange], + ); + + const label = currentOption?.label || SORT_LABEL_LOOKUP[currentOption?.value] || 'Title'; + + return ( + + {label} + + )} + triggerAriaLabel={`Sort by ${label}`} + triggerTitle={`Sort by ${label}`} + placeholder="Select sort field" + menuMinWidth={200} + align="start" + positionStrategy="absolute" + /> + ); +}; + export const createDocumentsTableHeaderActions = ({ viewMode, onViewModeChange, onRefresh, onShowDeskHelp = null, + sortField = 'title', + onSortFieldChange = null, + sortDirection = 'asc', + onSortDirectionToggle = null, + isFilterActive = false, + includeDescendants = true, + onToggleIncludeDescendants = null, }) => { const isListView = viewMode === 'list'; const isGridView = viewMode === 'grid'; const isDeskView = viewMode === 'desk'; + const sortDirectionIsDesc = sortDirection === 'desc'; + const sortDirectionTitle = sortDirectionIsDesc + ? 'Sorting Z → A. Click to switch to ascending.' + : 'Sorting A → Z. Click to switch to descending.'; + const includeDescendantsToggle = isFilterActive && typeof onToggleIncludeDescendants === 'function' + ? ( + + ) + : null; + + const sortControls = typeof onSortFieldChange === 'function' + ? ( +
+ + {typeof onSortDirectionToggle === 'function' ? ( + + ) : null} +
+ ) + : null; + return ( <> {isDeskView && typeof onShowDeskHelp === 'function' ? ( @@ -771,6 +882,22 @@ export const createDocumentsTableHeaderActions = ({ ) : null} + {includeDescendantsToggle ? ( + <> + {includeDescendantsToggle} + + + ) : null} + {sortControls ? ( + <> + {sortControls} + + + ) : null}
+ ) : null} {tags.map((tag) => { const isActive = activeTagSet.has(tag.id); const style = getTagColorStyle(tag.color); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index cd6fc7e..66e166c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -396,7 +396,7 @@ button.danger:hover:not([disabled]) { .panel-header button, .panel-header a.icon-button { display: inline-flex; - align-items: flex-start; + align-items: center; justify-content: flex-start; border: none; background: transparent; @@ -631,6 +631,80 @@ button.danger:hover:not([disabled]) { height: 1.4rem; } +.documents-actions__sort-group { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.documents-sort { + display: inline-flex; + align-items: center; + position: relative; +} + +.documents-sort__trigger { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.85rem; + white-space: nowrap; + padding: 0.25rem 0.5rem; + min-height: 2.1rem; +} + +.documents-sort__label { + display: inline-flex; + align-items: center; + line-height: 1.1; +} + +.documents-sort__trigger-content { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.documents-sort__quickmenu .menu__item, +.documents-sort__quickmenu .menu__item.active { + font-weight: 400; +} + +.documents-toolbar__toggle { + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.3rem; + background: transparent; + color: var(--muted); + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +.documents-toolbar__toggle:hover:not([disabled]) { + color: var(--fg); + border-color: var(--border); +} + +.documents-toolbar__toggle[aria-pressed='true'] { + border-color: var(--accent); + color: var(--accent); + background: var(--surface-subtle); +} + +.documents-sort__direction { + padding: 0.3rem 0.45rem; +} + +.documents-sort__direction[aria-pressed='true'] { + border-color: var(--border); + color: var(--muted); + background: transparent; +} + +.documents-sort__direction svg { + width: 1.1rem; + height: 1.1rem; +} + .app-shell { height: 100%; display: flex; @@ -1949,6 +2023,16 @@ button.danger:hover:not([disabled]) { box-shadow: 0 0 0 1.5px var(--sidebar-active-pill-border); } +.sidebar-tag-pill--untagged { + border: 1px dashed var(--border); + background: var(--surface-subtle); + color: var(--muted); +} + +.sidebar-tag-pill--untagged.active { + color: var(--fg); +} + .sidebar-tag-cloud--has-active .sidebar-tag-pill:not(.active) { opacity: 0.45; } @@ -2818,6 +2902,11 @@ button.danger:hover:not([disabled]) { white-space: nowrap; } +.documents-sort__trigger.quick-add__trigger { + padding: 0.25rem 0.5rem; + min-height: 2.1rem; +} + .quick-add__chip { display: inline-flex; align-items: center; diff --git a/frontend/src/ui/icons.js b/frontend/src/ui/icons.js index e8ca90c..1e85254 100644 --- a/frontend/src/ui/icons.js +++ b/frontend/src/ui/icons.js @@ -15,6 +15,8 @@ import { IconTextScan2, IconFolderPlus, IconFolder, + IconFolders, + IconFoldersOff, IconRefresh, IconRestore, IconMinusVertical, @@ -34,6 +36,8 @@ import { IconCircleDashedCheck, IconFile, IconLoader, + IconSortAscendingLetters, + IconSortDescendingLetters, } from '@tabler/icons-react'; import FolderSvg from '../assets/folder.svg'; @@ -190,6 +194,24 @@ export const FolderPlusIcon = ({ className, size = '1em', stroke = 1.6, ...rest /> ); +export const FoldersIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + +export const FoldersOffIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + export const RefreshIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( ( + +); + +export const SortDescendingLettersIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => ( + +); + export const IconX = ({ className, size = '1em', stroke = 1.6, ...rest }) => (