sorting
This commit is contained in:
+196
-21
@@ -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 ASSET_PRESIGN_TTL_MS = 240 * 1000; // backend issues 5 min tokens; refresh slightly early
|
||||||
|
|
||||||
const DEFAULT_FOLDER_NAME = 'Documents';
|
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 ROW_KEY_SEPARATOR = ':';
|
||||||
const DOCUMENT_ROW_PREFIX = 'document';
|
const DOCUMENT_ROW_PREFIX = 'document';
|
||||||
@@ -204,6 +208,46 @@ const AppLayout = () => {
|
|||||||
return 'list';
|
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 [deskHelpOpen, setDeskHelpOpen] = useState(false);
|
||||||
const lastNonDeskViewRef = useRef(documentsViewMode === 'desk' ? 'list' : documentsViewMode);
|
const lastNonDeskViewRef = useRef(documentsViewMode === 'desk' ? 'list' : documentsViewMode);
|
||||||
|
|
||||||
@@ -218,6 +262,42 @@ const AppLayout = () => {
|
|||||||
setDeskHelpOpen(false);
|
setDeskHelpOpen(false);
|
||||||
}
|
}
|
||||||
}, [documentsViewMode, deskHelpOpen]);
|
}, [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 tokenRef = useRef(token);
|
||||||
const refreshPromiseRef = useRef(null);
|
const refreshPromiseRef = useRef(null);
|
||||||
const breadcrumbFetchRef = useRef(new Set());
|
const breadcrumbFetchRef = useRef(new Set());
|
||||||
@@ -281,11 +361,16 @@ const AppLayout = () => {
|
|||||||
);
|
);
|
||||||
const toggleTagFilter = useCallback((tagId) => {
|
const toggleTagFilter = useCallback((tagId) => {
|
||||||
if (!tagId) return;
|
if (!tagId) return;
|
||||||
setActiveTagFilters((previous) =>
|
setActiveTagFilters((previous) => {
|
||||||
previous.includes(tagId)
|
if (tagId === TAG_FILTER_UNTAGGED) {
|
||||||
? previous.filter((id) => id !== tagId)
|
return previous.includes(TAG_FILTER_UNTAGGED) ? [] : [TAG_FILTER_UNTAGGED];
|
||||||
: previous.concat([tagId]),
|
}
|
||||||
);
|
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) => {
|
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));
|
const initialRefreshAttemptedRef = useRef(Boolean(token));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -312,6 +410,7 @@ const AppLayout = () => {
|
|||||||
setActiveTagFilters([]);
|
setActiveTagFilters([]);
|
||||||
setActiveCorrespondentFilters([]);
|
setActiveCorrespondentFilters([]);
|
||||||
setSearchLoading(false);
|
setSearchLoading(false);
|
||||||
|
setSearchIncludeDescendants(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSearchChange = useCallback((value) => {
|
const handleSearchChange = useCallback((value) => {
|
||||||
@@ -982,13 +1081,32 @@ const AppLayout = () => {
|
|||||||
const ensureFolderData = useCallback(
|
const ensureFolderData = useCallback(
|
||||||
async (
|
async (
|
||||||
folderId,
|
folderId,
|
||||||
{ force = false, includeDocuments = true, prefetchDepth = 0 } = {},
|
{
|
||||||
|
force = false,
|
||||||
|
includeDocuments = true,
|
||||||
|
prefetchDepth = 0,
|
||||||
|
sortField: overrideSortField,
|
||||||
|
sortDirection: overrideSortDirection,
|
||||||
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
const requestTenantId = tenantIdRef.current;
|
const requestTenantId = tenantIdRef.current;
|
||||||
const cached = folderContents.get(folderId);
|
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) {
|
if (!force && cached) {
|
||||||
const includesDocuments = Boolean(cached.__includesDocuments);
|
const includesDocuments = Boolean(cached.__includesDocuments);
|
||||||
if (!includeDocuments || includesDocuments) {
|
if (!includeDocuments || (includesDocuments && cachedSortMatches)) {
|
||||||
if (prefetchDepth > 0) {
|
if (prefetchDepth > 0) {
|
||||||
const subfolders = Array.isArray(cached.subfolders) ? cached.subfolders : [];
|
const subfolders = Array.isArray(cached.subfolders) ? cached.subfolders : [];
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
@@ -1006,18 +1124,28 @@ const AppLayout = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const path = folderId === 'root' ? 'root' : folderId;
|
const path = folderId === 'root' ? 'root' : folderId;
|
||||||
const params = includeDocuments
|
const params = {};
|
||||||
? undefined
|
if (!includeDocuments) {
|
||||||
: { include_documents: false };
|
params.include_documents = false;
|
||||||
const { data } = await api.get(`/folders/${path}/contents`, {
|
} else {
|
||||||
params,
|
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 hydrated = assetManager.hydrateFolderContents(data);
|
||||||
const childFolders = Array.isArray(data.subfolders) ? data.subfolders : [];
|
const childFolders = Array.isArray(data.subfolders) ? data.subfolders : [];
|
||||||
const childIds = childFolders.map((child) => child.id);
|
const childIds = childFolders.map((child) => child.id);
|
||||||
|
|
||||||
|
const enriched = {
|
||||||
|
...hydrated,
|
||||||
|
__includesDocuments: includeDocuments,
|
||||||
|
__sortField: includeDocuments ? sortField : cachedSortField,
|
||||||
|
__sortDirection: includeDocuments ? sortDirection : cachedSortDirection,
|
||||||
|
};
|
||||||
|
|
||||||
if (tenantIdRef.current !== requestTenantId) {
|
if (tenantIdRef.current !== requestTenantId) {
|
||||||
return { ...hydrated, __includesDocuments: includeDocuments };
|
return enriched;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFolderNodes((prev) => {
|
setFolderNodes((prev) => {
|
||||||
@@ -1089,11 +1217,6 @@ const AppLayout = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const enriched = {
|
|
||||||
...hydrated,
|
|
||||||
__includesDocuments: includeDocuments,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (includeDocuments) {
|
if (includeDocuments) {
|
||||||
setFolderContents((prev) => {
|
setFolderContents((prev) => {
|
||||||
if (tenantIdRef.current !== requestTenantId) {
|
if (tenantIdRef.current !== requestTenantId) {
|
||||||
@@ -1118,6 +1241,8 @@ const AppLayout = () => {
|
|||||||
? existingEntry.documents
|
? existingEntry.documents
|
||||||
: hydrated.documents,
|
: hydrated.documents,
|
||||||
__includesDocuments: existingEntry.__includesDocuments || false,
|
__includesDocuments: existingEntry.__includesDocuments || false,
|
||||||
|
__sortField: existingEntry.__sortField ?? enriched.__sortField,
|
||||||
|
__sortDirection: existingEntry.__sortDirection ?? enriched.__sortDirection,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
next.set(folderId, enriched);
|
next.set(folderId, enriched);
|
||||||
@@ -1128,7 +1253,7 @@ const AppLayout = () => {
|
|||||||
|
|
||||||
return enriched;
|
return enriched;
|
||||||
},
|
},
|
||||||
[assetManager, folderContents],
|
[assetManager, documentsSortDirectionRef, documentsSortFieldRef, folderContents],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isInvalidFolderDrop = useCallback(
|
const isInvalidFolderDrop = useCallback(
|
||||||
@@ -1461,6 +1586,22 @@ const AppLayout = () => {
|
|||||||
}
|
}
|
||||||
}, [selectedFolder, ensureFolderData, applySelectedFolder, notifyApiError]);
|
}, [selectedFolder, ensureFolderData, applySelectedFolder, notifyApiError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sortRefreshReadyRef.current) {
|
||||||
|
sortRefreshReadyRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isFilterActive && token) {
|
||||||
|
refreshCurrentFolder();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
documentsSortField,
|
||||||
|
documentsSortDirection,
|
||||||
|
isFilterActive,
|
||||||
|
refreshCurrentFolder,
|
||||||
|
token,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleDocumentCorrespondentAttach = useCallback(
|
const handleDocumentCorrespondentAttach = useCallback(
|
||||||
async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => {
|
async ({ documentId, correspondentId }, { notify = true, refresh = true } = {}) => {
|
||||||
if (!documentId || !correspondentId) {
|
if (!documentId || !correspondentId) {
|
||||||
@@ -3719,7 +3860,16 @@ const AppLayout = () => {
|
|||||||
params.query = trimmedQuery;
|
params.query = trimmedQuery;
|
||||||
}
|
}
|
||||||
if (activeTagFilters.length) {
|
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) {
|
if (activeCorrespondentFilters.length) {
|
||||||
params.correspondents = activeCorrespondentFilters.join(',');
|
params.correspondents = activeCorrespondentFilters.join(',');
|
||||||
@@ -3728,6 +3878,15 @@ const AppLayout = () => {
|
|||||||
if (folderIdentifier) {
|
if (folderIdentifier) {
|
||||||
params.folder_id = 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 });
|
const { data } = await api.get('/documents', { params });
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
@@ -3803,6 +3962,9 @@ const AppLayout = () => {
|
|||||||
searchQuery,
|
searchQuery,
|
||||||
activeTagFilters,
|
activeTagFilters,
|
||||||
activeCorrespondentFilters,
|
activeCorrespondentFilters,
|
||||||
|
searchIncludeDescendants,
|
||||||
|
documentsSortField,
|
||||||
|
documentsSortDirection,
|
||||||
selectedFolder,
|
selectedFolder,
|
||||||
notifyApiError,
|
notifyApiError,
|
||||||
assetManager,
|
assetManager,
|
||||||
@@ -4561,6 +4723,12 @@ const AppLayout = () => {
|
|||||||
onCorrespondentClick: toggleCorrespondentFilter,
|
onCorrespondentClick: toggleCorrespondentFilter,
|
||||||
onDocumentTagDrop: handleDocumentTagDrop,
|
onDocumentTagDrop: handleDocumentTagDrop,
|
||||||
viewMode: documentsViewMode,
|
viewMode: documentsViewMode,
|
||||||
|
sortField: documentsSortField,
|
||||||
|
sortDirection: documentsSortDirection,
|
||||||
|
onSortFieldChange: handleDocumentsSortFieldChange,
|
||||||
|
onSortDirectionToggle: handleDocumentsSortDirectionToggle,
|
||||||
|
searchIncludeDescendants,
|
||||||
|
onToggleSearchIncludeDescendants: toggleSearchIncludeDescendants,
|
||||||
onViewModeChange: handleDocumentsViewModeChange,
|
onViewModeChange: handleDocumentsViewModeChange,
|
||||||
onClearSelection: clearDocumentSelection,
|
onClearSelection: clearDocumentSelection,
|
||||||
onDeleteSelection: handleDeleteSelection,
|
onDeleteSelection: handleDeleteSelection,
|
||||||
@@ -4586,6 +4754,8 @@ const AppLayout = () => {
|
|||||||
currentSubfolders,
|
currentSubfolders,
|
||||||
documents,
|
documents,
|
||||||
documentsViewMode,
|
documentsViewMode,
|
||||||
|
documentsSortField,
|
||||||
|
documentsSortDirection,
|
||||||
draggedDocumentIds,
|
draggedDocumentIds,
|
||||||
draggedFolderId,
|
draggedFolderId,
|
||||||
focusedRowKey,
|
focusedRowKey,
|
||||||
@@ -4595,6 +4765,8 @@ const AppLayout = () => {
|
|||||||
handleDocumentTagDrop,
|
handleDocumentTagDrop,
|
||||||
handleDocumentTitleUpdate,
|
handleDocumentTitleUpdate,
|
||||||
handleDocumentsViewModeChange,
|
handleDocumentsViewModeChange,
|
||||||
|
handleDocumentsSortFieldChange,
|
||||||
|
handleDocumentsSortDirectionToggle,
|
||||||
handleFolderDragEnd,
|
handleFolderDragEnd,
|
||||||
handleFolderDragStart,
|
handleFolderDragStart,
|
||||||
handleFolderRename,
|
handleFolderRename,
|
||||||
@@ -4607,6 +4779,7 @@ const AppLayout = () => {
|
|||||||
refreshCurrentFolder,
|
refreshCurrentFolder,
|
||||||
searchLoading,
|
searchLoading,
|
||||||
searchResults,
|
searchResults,
|
||||||
|
searchIncludeDescendants,
|
||||||
selectFolder,
|
selectFolder,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
selectedEntries,
|
selectedEntries,
|
||||||
@@ -4627,6 +4800,7 @@ const AppLayout = () => {
|
|||||||
handleBulkSelectionReanalyze,
|
handleBulkSelectionReanalyze,
|
||||||
folderOptions,
|
folderOptions,
|
||||||
moveDocumentsToFolder,
|
moveDocumentsToFolder,
|
||||||
|
toggleSearchIncludeDescendants,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -4647,6 +4821,7 @@ const AppLayout = () => {
|
|||||||
onCreateFolder: handlePromptCreateFolder,
|
onCreateFolder: handlePromptCreateFolder,
|
||||||
creatingFolder,
|
creatingFolder,
|
||||||
tags,
|
tags,
|
||||||
|
untaggedFilterId: TAG_FILTER_UNTAGGED,
|
||||||
activeTagIds: activeTagFilters,
|
activeTagIds: activeTagFilters,
|
||||||
onToggleTagFilter: toggleTagFilter,
|
onToggleTagFilter: toggleTagFilter,
|
||||||
onCreateTag: (label) => handleTagCreate({ label }),
|
onCreateTag: (label) => handleTagCreate({ label }),
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import {
|
|||||||
RefreshIcon,
|
RefreshIcon,
|
||||||
MinusVerticalIcon,
|
MinusVerticalIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
|
FoldersIcon,
|
||||||
|
FoldersOffIcon,
|
||||||
|
SortAscendingLettersIcon,
|
||||||
|
SortDescendingLettersIcon,
|
||||||
} from '../ui/icons';
|
} from '../ui/icons';
|
||||||
|
import QuickAddMenu from '../ui/QuickAddMenu';
|
||||||
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
|
||||||
import createWorkspaceSurfaceConfig from './workspaceHeader';
|
import createWorkspaceSurfaceConfig from './workspaceHeader';
|
||||||
import DetailPanel from '../detail/DetailPanel';
|
import DetailPanel from '../detail/DetailPanel';
|
||||||
@@ -25,6 +30,19 @@ const EntryType = {
|
|||||||
document: 'document',
|
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 = ({
|
const DocumentsPanel = ({
|
||||||
currentFolderName,
|
currentFolderName,
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
@@ -545,7 +563,6 @@ const DocumentsPanel = ({
|
|||||||
const showDefaultEmptyState = !showingSearchResults && !isFilterActive && entries.length === 0;
|
const showDefaultEmptyState = !showingSearchResults && !isFilterActive && entries.length === 0;
|
||||||
const showListSearchEmptyState = showingSearchResults && !hasDocumentEntries && !isGridView && !isSearchLoading;
|
const showListSearchEmptyState = showingSearchResults && !hasDocumentEntries && !isGridView && !isSearchLoading;
|
||||||
const showGridSearchEmptyState = isGridView && showingSearchResults && !hasDocumentEntries && !isSearchLoading;
|
const showGridSearchEmptyState = isGridView && showingSearchResults && !hasDocumentEntries && !isSearchLoading;
|
||||||
const showSearchHint = showingSearchResults && rows.length > 0;
|
|
||||||
const breadcrumbEntries = useMemo(() => (Array.isArray(breadcrumbs) ? breadcrumbs.filter(Boolean) : []), [breadcrumbs]);
|
const breadcrumbEntries = useMemo(() => (Array.isArray(breadcrumbs) ? breadcrumbs.filter(Boolean) : []), [breadcrumbs]);
|
||||||
const trailEntries = useMemo(() => {
|
const trailEntries = useMemo(() => {
|
||||||
if (!breadcrumbEntries.length) {
|
if (!breadcrumbEntries.length) {
|
||||||
@@ -724,11 +741,6 @@ const DocumentsPanel = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{showSearchHint && (
|
|
||||||
<div className="search-hint">
|
|
||||||
Showing {rows.length} document{rows.length === 1 ? '' : 's'} in {currentFolderName} and subfolders.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -743,16 +755,115 @@ const DocumentsPanel = ({
|
|||||||
|
|
||||||
export default 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 (
|
||||||
|
<QuickAddMenu
|
||||||
|
className="documents-sort__quickmenu"
|
||||||
|
options={options}
|
||||||
|
onSelectOption={handleSelect}
|
||||||
|
triggerClassName="view-toggle__button documents-sort__trigger quick-add__trigger"
|
||||||
|
triggerContent={(
|
||||||
|
<span className="documents-sort__trigger-content">
|
||||||
|
<span className="documents-sort__label">{label}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
triggerAriaLabel={`Sort by ${label}`}
|
||||||
|
triggerTitle={`Sort by ${label}`}
|
||||||
|
placeholder="Select sort field"
|
||||||
|
menuMinWidth={200}
|
||||||
|
align="start"
|
||||||
|
positionStrategy="absolute"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const createDocumentsTableHeaderActions = ({
|
export const createDocumentsTableHeaderActions = ({
|
||||||
viewMode,
|
viewMode,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onShowDeskHelp = null,
|
onShowDeskHelp = null,
|
||||||
|
sortField = 'title',
|
||||||
|
onSortFieldChange = null,
|
||||||
|
sortDirection = 'asc',
|
||||||
|
onSortDirectionToggle = null,
|
||||||
|
isFilterActive = false,
|
||||||
|
includeDescendants = true,
|
||||||
|
onToggleIncludeDescendants = null,
|
||||||
}) => {
|
}) => {
|
||||||
const isListView = viewMode === 'list';
|
const isListView = viewMode === 'list';
|
||||||
const isGridView = viewMode === 'grid';
|
const isGridView = viewMode === 'grid';
|
||||||
const isDeskView = viewMode === 'desk';
|
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'
|
||||||
|
? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button documents-toolbar__toggle"
|
||||||
|
onClick={onToggleIncludeDescendants}
|
||||||
|
aria-pressed={!includeDescendants}
|
||||||
|
aria-label={includeDescendants ? 'Include subfolders' : 'Limit to current folder'}
|
||||||
|
title={includeDescendants
|
||||||
|
? 'Including subfolders. Click to limit the search to the current folder.'
|
||||||
|
: 'Limiting to the current folder. Click to include subfolders again.'}
|
||||||
|
>
|
||||||
|
{includeDescendants ? <FoldersIcon /> : <FoldersOffIcon />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const sortControls = typeof onSortFieldChange === 'function'
|
||||||
|
? (
|
||||||
|
<div className="documents-actions__sort-group">
|
||||||
|
<SortFieldQuickMenu sortField={sortField} onChange={onSortFieldChange} />
|
||||||
|
{typeof onSortDirectionToggle === 'function' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button documents-toolbar__toggle documents-sort__direction"
|
||||||
|
onClick={onSortDirectionToggle}
|
||||||
|
aria-pressed={sortDirectionIsDesc}
|
||||||
|
aria-label={sortDirectionIsDesc ? 'Sort descending' : 'Sort ascending'}
|
||||||
|
title={sortDirectionTitle}
|
||||||
|
>
|
||||||
|
{sortDirectionIsDesc ? (
|
||||||
|
<SortDescendingLettersIcon size={18} />
|
||||||
|
) : (
|
||||||
|
<SortAscendingLettersIcon size={18} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isDeskView && typeof onShowDeskHelp === 'function' ? (
|
{isDeskView && typeof onShowDeskHelp === 'function' ? (
|
||||||
@@ -771,6 +882,22 @@ export const createDocumentsTableHeaderActions = ({
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
{includeDescendantsToggle ? (
|
||||||
|
<>
|
||||||
|
{includeDescendantsToggle}
|
||||||
|
<span className="main-content__actions-divider" aria-hidden="true">
|
||||||
|
<MinusVerticalIcon />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{sortControls ? (
|
||||||
|
<>
|
||||||
|
{sortControls}
|
||||||
|
<span className="main-content__actions-divider" aria-hidden="true">
|
||||||
|
<MinusVerticalIcon />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<div className="view-toggle" role="group" aria-label="Change view">
|
<div className="view-toggle" role="group" aria-label="Change view">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -828,9 +955,14 @@ export const createDocumentsSurface = ({
|
|||||||
currentFolderName,
|
currentFolderName,
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
searchResults,
|
searchResults,
|
||||||
|
isFilterActive,
|
||||||
viewMode,
|
viewMode,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
sortField,
|
||||||
|
sortDirection,
|
||||||
|
onSortFieldChange,
|
||||||
|
onSortDirectionToggle,
|
||||||
selectedDocumentIds,
|
selectedDocumentIds,
|
||||||
selectedFolderIds,
|
selectedFolderIds,
|
||||||
onDeleteSelection,
|
onDeleteSelection,
|
||||||
@@ -846,6 +978,8 @@ export const createDocumentsSurface = ({
|
|||||||
onBulkReanalyze,
|
onBulkReanalyze,
|
||||||
folderOptions,
|
folderOptions,
|
||||||
onMoveDocumentsToFolder,
|
onMoveDocumentsToFolder,
|
||||||
|
searchIncludeDescendants,
|
||||||
|
onToggleSearchIncludeDescendants,
|
||||||
} = tableProps;
|
} = tableProps;
|
||||||
|
|
||||||
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
const title = Array.isArray(searchResults) ? 'Search results' : currentFolderName;
|
||||||
@@ -862,6 +996,13 @@ export const createDocumentsSurface = ({
|
|||||||
viewMode,
|
viewMode,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
sortField,
|
||||||
|
onSortFieldChange,
|
||||||
|
sortDirection,
|
||||||
|
onSortDirectionToggle,
|
||||||
|
isFilterActive,
|
||||||
|
includeDescendants: searchIncludeDescendants,
|
||||||
|
onToggleIncludeDescendants: onToggleSearchIncludeDescendants,
|
||||||
});
|
});
|
||||||
|
|
||||||
const floatingActions = selectionCount > 0
|
const floatingActions = selectionCount > 0
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ const Sidebar = ({
|
|||||||
onCreateFolder,
|
onCreateFolder,
|
||||||
creatingFolder = false,
|
creatingFolder = false,
|
||||||
tags = [],
|
tags = [],
|
||||||
|
untaggedFilterId = null,
|
||||||
activeTagIds = [],
|
activeTagIds = [],
|
||||||
onToggleTagFilter,
|
onToggleTagFilter,
|
||||||
correspondents = [],
|
correspondents = [],
|
||||||
@@ -215,6 +216,7 @@ const Sidebar = ({
|
|||||||
);
|
);
|
||||||
const handleToggleTag = onToggleTagFilter || (() => {});
|
const handleToggleTag = onToggleTagFilter || (() => {});
|
||||||
const activeTagSet = new Set(activeTagIds);
|
const activeTagSet = new Set(activeTagIds);
|
||||||
|
const untaggedActive = untaggedFilterId ? activeTagSet.has(untaggedFilterId) : false;
|
||||||
const handleManageTags = onManageTags || (() => {});
|
const handleManageTags = onManageTags || (() => {});
|
||||||
const handleManageCorrespondents = onManageCorrespondents || (() => {});
|
const handleManageCorrespondents = onManageCorrespondents || (() => {});
|
||||||
const handleCreateTag = useCallback(async () => {
|
const handleCreateTag = useCallback(async () => {
|
||||||
@@ -619,6 +621,20 @@ const Sidebar = ({
|
|||||||
}`}
|
}`}
|
||||||
role="list"
|
role="list"
|
||||||
>
|
>
|
||||||
|
{untaggedFilterId ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="listitem"
|
||||||
|
className={`sidebar-tag-pill sidebar-tag-pill--untagged${
|
||||||
|
untaggedActive ? ' active' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleToggleTag(untaggedFilterId)}
|
||||||
|
aria-pressed={untaggedActive}
|
||||||
|
draggable={false}
|
||||||
|
>
|
||||||
|
No tag assigned
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
{tags.map((tag) => {
|
{tags.map((tag) => {
|
||||||
const isActive = activeTagSet.has(tag.id);
|
const isActive = activeTagSet.has(tag.id);
|
||||||
const style = getTagColorStyle(tag.color);
|
const style = getTagColorStyle(tag.color);
|
||||||
|
|||||||
+90
-1
@@ -396,7 +396,7 @@ button.danger:hover:not([disabled]) {
|
|||||||
.panel-header button,
|
.panel-header button,
|
||||||
.panel-header a.icon-button {
|
.panel-header a.icon-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -631,6 +631,80 @@ button.danger:hover:not([disabled]) {
|
|||||||
height: 1.4rem;
|
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 {
|
.app-shell {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1949,6 +2023,16 @@ button.danger:hover:not([disabled]) {
|
|||||||
box-shadow: 0 0 0 1.5px var(--sidebar-active-pill-border);
|
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) {
|
.sidebar-tag-cloud--has-active .sidebar-tag-pill:not(.active) {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
@@ -2818,6 +2902,11 @@ button.danger:hover:not([disabled]) {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.documents-sort__trigger.quick-add__trigger {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
min-height: 2.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.quick-add__chip {
|
.quick-add__chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
IconTextScan2,
|
IconTextScan2,
|
||||||
IconFolderPlus,
|
IconFolderPlus,
|
||||||
IconFolder,
|
IconFolder,
|
||||||
|
IconFolders,
|
||||||
|
IconFoldersOff,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconRestore,
|
IconRestore,
|
||||||
IconMinusVertical,
|
IconMinusVertical,
|
||||||
@@ -34,6 +36,8 @@ import {
|
|||||||
IconCircleDashedCheck,
|
IconCircleDashedCheck,
|
||||||
IconFile,
|
IconFile,
|
||||||
IconLoader,
|
IconLoader,
|
||||||
|
IconSortAscendingLetters,
|
||||||
|
IconSortDescendingLetters,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import FolderSvg from '../assets/folder.svg';
|
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 }) => (
|
||||||
|
<IconFolders
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FoldersOffIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconFoldersOff
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
export const RefreshIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
export const RefreshIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
<IconRefresh
|
<IconRefresh
|
||||||
className={composeClassName('icon', className)}
|
className={composeClassName('icon', className)}
|
||||||
@@ -262,6 +284,24 @@ export const IconFileStack = ({ className, size = 24, stroke = 160, ...rest }) =
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const SortAscendingLettersIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconSortAscendingLetters
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SortDescendingLettersIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
|
<IconSortDescendingLetters
|
||||||
|
className={composeClassName('icon', className)}
|
||||||
|
size={size}
|
||||||
|
stroke={stroke}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
export const IconX = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
export const IconX = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
|
||||||
<TablerIconX
|
<TablerIconX
|
||||||
className={composeClassName('icon', className)}
|
className={composeClassName('icon', className)}
|
||||||
|
|||||||
Reference in New Issue
Block a user