From 8137860859a6a45eaedf4360339cb069da2022ac Mon Sep 17 00:00:00 2001 From: Jacob Rief Date: Wed, 13 Nov 2024 11:40:49 +0100 Subject: [PATCH] feat: infinite scroll for file browser view --- client/browser/FileSelectDialog.tsx | 126 +++++++++++++++++++++------- client/browser/FolderStructure.tsx | 12 +-- client/browser/MenuBar.tsx | 12 ++- client/scss/_menubar.scss | 51 +++-------- client/scss/finder-browser.scss | 8 +- finder/browser/views.py | 24 ++++-- 6 files changed, 143 insertions(+), 90 deletions(-) diff --git a/client/browser/FileSelectDialog.tsx b/client/browser/FileSelectDialog.tsx index 8171cc9b8..98f38129c 100644 --- a/client/browser/FileSelectDialog.tsx +++ b/client/browser/FileSelectDialog.tsx @@ -7,7 +7,7 @@ import React, { useRef, useState, } from 'react'; -import {InView} from 'react-intersection-observer'; +import {useInView} from 'react-intersection-observer'; import {Tooltip} from 'react-tooltip'; import FigureLabels from '../common/FigureLabels'; import FileUploader from '../common/FileUploader'; @@ -50,26 +50,41 @@ function Figure(props) { } -const FilesList = memo((props: any) => { - const {files, numFiles, selectFile} = props; - const [{offset, limit}, setOffset] = useState({offset: 0, limit: 10}); +const ScrollSpy = (props) => { + const {fetchFiles} = props; + const {ref, inView} = useInView({ + triggerOnce: true, + onChange: (loadMore) => { + if (loadMore && !inView) { + fetchFiles(); + } + }, + }); + console.log('ScrollSpy', inView); + if (inView) { + console.log('already visible'); + fetchFiles(); + } - console.log('FolderList', numFiles, files); + return ( +
+ ); +}; - function loadMore(inView, entry) { - if (inView) { - console.log('load more:', entry.target); - } - } + +const FilesList = memo((props: any) => { + const {structure, fetchFiles, selectFile} = props; + + console.log('FolderList', structure); return ( ); @@ -82,7 +97,8 @@ export default function FileSelectDialog(props) { root_folder: null, last_folder: null, files: null, - num_files: 0, + offset: null, + search_query: '', labels: [], }); const [uploadedFile, setUploadedFile] = useState(null); @@ -110,6 +126,48 @@ export default function FileSelectDialog(props) { }; }, [dialog]); + const setCurrentFolder = (folderId) => { + setStructure(prevStructure => { + const newStructure = Object.assign(structure, { + ...prevStructure, + last_folder: folderId, + files: [], + offset: null, + search_query: '', + }); + fetchFiles(); + return newStructure; + }); + }; + + const setSearchQuery = (query) => { + setStructure(prevStructure => { + const newStructure = Object.assign(structure, { + ...prevStructure, + files: [], + offset: null, + search_query: query, + }); + fetchFiles(); + return newStructure; + }); + }; + + const refreshFilesList = () => { + setStructure(prevStructure => { + const newStructure = Object.assign(structure, { + root_folder: prevStructure.root_folder, + files: [], + last_folder: prevStructure.last_folder, + offset: null, + search_query: prevStructure.search_query, + labels: prevStructure.labels, + }); + fetchFiles(); + return newStructure; + }); + }; + async function getStructure() { const response = await fetch(`${baseUrl}structure/${realm}`); if (response.ok) { @@ -119,29 +177,29 @@ export default function FileSelectDialog(props) { } } - async function fetchFiles(folderId: string, searchQuery='') { + async function fetchFiles() { const fetchUrl = (() => { - if (searchQuery) { - const params = new URLSearchParams({q: searchQuery}); - return `${baseUrl}${folderId}/search?${params.toString()}`; + const params = new URLSearchParams(); + if (structure.offset !== null) { + params.set('offset', String(structure.offset)); } - return `${baseUrl}${folderId}/list`; + if (structure.search_query) { + params.set('q', structure.search_query); + return `${baseUrl}${structure.last_folder}/search?${params.toString()}`; + } + return `${baseUrl}${structure.last_folder}/list?${params.toString()}`; })(); - const newStructure = { - root_folder: structure.root_folder, - last_folder: folderId, - files: null, - num_files: 0, - labels: structure.labels, - }; const response = await fetch(fetchUrl); if (response.ok) { const body = await response.json(); - newStructure.files = body.files; + setStructure({ + ...structure, + files: structure.files.concat(body.files), + offset: body.offset, + }); } else { console.error(response); } - setStructure(newStructure); } function refreshStructure() { @@ -162,8 +220,8 @@ export default function FileSelectDialog(props) { settings={{csrfToken, baseUrl, selectFile, labels: structure.labels}} /> : <> uploaderRef.current.openUploader()} labels={structure.labels} /> @@ -174,7 +232,7 @@ export default function FileSelectDialog(props) { baseUrl={baseUrl} folder={structure.root_folder} lastFolderId={structure.last_folder} - fetchFiles={fetchFiles} + setCurrentFolder={setCurrentFolder} refreshStructure={refreshStructure} />} @@ -187,7 +245,11 @@ export default function FileSelectDialog(props) { >{ structure.files === null ?
{gettext("Loading files…")}
: - + } } diff --git a/client/browser/FolderStructure.tsx b/client/browser/FolderStructure.tsx index c5c35dc84..9749240a1 100644 --- a/client/browser/FolderStructure.tsx +++ b/client/browser/FolderStructure.tsx @@ -8,10 +8,10 @@ import RootIcon from '../icons/root.svg'; function FolderEntry(props) { - const {folder, toggleOpen, fetchFiles, isCurrent} = props; + const {folder, toggleOpen, setCurrentFolder, isCurrent} = props; if (folder.is_root) { - return ( fetchFiles(folder.id)}>); + return ( setCurrentFolder(folder.id)}>); } return (<> @@ -20,7 +20,7 @@ function FolderEntry(props) { } {isCurrent ? {folder.name} : - fetchFiles(folder.id)} role="button"> + setCurrentFolder(folder.id)} role="button"> {folder.name} } @@ -29,7 +29,7 @@ function FolderEntry(props) { export default function FolderStructure(props) { - const {baseUrl, folder, lastFolderId, fetchFiles, refreshStructure} = props; + const {baseUrl, folder, lastFolderId, setCurrentFolder, refreshStructure} = props; async function fetchChildren() { const response = await fetch(`${baseUrl}${folder.id}/fetch`); @@ -62,7 +62,7 @@ export default function FolderStructure(props) { {folder.is_open && ( @@ -73,7 +73,7 @@ export default function FolderStructure(props) { baseUrl={baseUrl} folder={child} lastFolderId={lastFolderId} - fetchFiles={fetchFiles} + setCurrentFolder={setCurrentFolder} refreshStructure={refreshStructure} /> ))} diff --git a/client/browser/MenuBar.tsx b/client/browser/MenuBar.tsx index f301b2a34..26c7e7c42 100644 --- a/client/browser/MenuBar.tsx +++ b/client/browser/MenuBar.tsx @@ -8,17 +8,17 @@ import UploadIcon from '../icons/upload.svg'; export default function MenuBar(props) { - const {lastFolderId, fetchFiles, openUploader, labels} = props; + const {refreshFilesList, setSearchQuery, openUploader, labels} = props; const searchRef = useRef(null); const [searchRealm, setSearchRealm] = useSearchRealm('current'); function handleSearch(event) { const performSearch = () => { const searchQuery = searchRef.current.value || ''; - fetchFiles(lastFolderId, searchQuery); + setSearchQuery(searchQuery); }; const resetSearch = () => { - fetchFiles(lastFolderId); + setSearchQuery(''); }; if (event.type === 'change' && searchRef.current.value.length === 0) { @@ -47,8 +47,6 @@ export default function MenuBar(props) { }; } - console.log('Menu', lastFolderId); - return (
  • @@ -75,8 +73,8 @@ export default function MenuBar(props) {
  • - fetchFiles(lastFolderId)}/> - {labels && fetchFiles(lastFolderId)} labels={labels} />} + + {labels && }
  • diff --git a/client/scss/_menubar.scss b/client/scss/_menubar.scss index f76bfaf0f..64ae83fdf 100644 --- a/client/scss/_menubar.scss +++ b/client/scss/_menubar.scss @@ -48,8 +48,6 @@ min-width: 100px; height: 100%; display: block; - color: $body-fg-color; - background-color: $body-bg-color; margin: 0; padding: 2px 8px; border: 1px solid $border-color; @@ -86,17 +84,17 @@ .search-realm { display: flex; - // [role="combobox"] { - // li:nth-child(2) { - // padding-bottom: 4px; - // border-bottom: 1px solid $border-color; - // margin-bottom: 4px; - // } - // } } } } + &:not(:has(> input:placeholder-shown)) { + background-color: $selected-row-color; + > input { + background-color: $selected-row-color; + } + } + &:has(> input:focus-visible) { box-shadow: $focus-visible-box-shadow; } @@ -104,10 +102,6 @@ &[aria-haspopup="listbox"]:has(>[role~="listbox"]) { width: auto; - - //&.with-caret { - // padding-right: 0.25rem; - //} } &.sorting-options { @@ -119,6 +113,7 @@ &.filter-by-label { margin-right: auto; + ul > li { input[type="checkbox"] { vertical-align: initial; @@ -132,21 +127,15 @@ margin-right: 0.5rem; } } - } - //&.filter-by-label + &.sorting-options { - // margin-left: auto; - //} + &:has(input:checked) { + background-color: $selected-row-color; + } + } &.extra-menu [role~="listbox"] { right: 0; margin-inline-end: inherit; - - //> li:nth-child(5):not(:last-child) { - // padding-bottom: 4px; - // border-bottom: 1px solid $border-color; - // margin-bottom: 4px; - //} } svg { @@ -165,22 +154,6 @@ left: -250px; right: -250px; - //li { - // padding: 0 2rem 0 0.5rem; - // width: auto; - // cursor: pointer; - // display: flex; - // align-items: center; - // line-height: 32px; - // font-size: 16px; - // - // &.active::after { - // content: "✔"; - // position: absolute; - // right: 10px; - // } - //} - svg { color: $body-fg-color; } diff --git a/client/scss/finder-browser.scss b/client/scss/finder-browser.scss index fa9fb9f38..f0faababa 100644 --- a/client/scss/finder-browser.scss +++ b/client/scss/finder-browser.scss @@ -158,7 +158,7 @@ dialog { flex-direction: row; gap: 10px; - li:not(.status) { + li:has(> .figure) { min-height: 150px; width: 125px; @@ -208,6 +208,12 @@ dialog { } } } + + .scroll-spy { + display: block; + width: 125px; // force to a new line + height: 1px; + } } .status { diff --git a/finder/browser/views.py b/finder/browser/views.py index 73cc778fe..9a56f704f 100644 --- a/finder/browser/views.py +++ b/finder/browser/views.py @@ -27,6 +27,7 @@ class BrowserView(View): The view for web component . """ action = None + limit = 25 def dispatch(self, request, *args, **kwargs): action = getattr(self, self.action, None) @@ -117,6 +118,7 @@ def open(self, request, folder_id): if folder_id not in request.session['finder.open_folders']: request.session['finder.open_folders'].append(folder_id) request.session.modified = True + return {'id': folder_id} def close(self, request, folder_id): """ @@ -129,6 +131,7 @@ def close(self, request, folder_id): pass else: request.session.modified = True + return {'id': folder_id} def list(self, request, folder_id): """ @@ -136,15 +139,17 @@ def list(self, request, folder_id): """ request.session['finder.last_folder'] = str(folder_id) offset = int(request.GET.get('offset', 0)) - limit = int(request.GET.get('limit', 10)) lookup = lookup_by_label(request) unified_queryset = FileModel.objects.filter_unified(parent_id=folder_id, is_folder=False, **lookup) - num_files = unified_queryset.count() + next_offset = offset + self.limit + if next_offset >= unified_queryset.count(): + next_offset = None unified_queryset = sort_by_attribute(request, unified_queryset) annotate_unified_queryset(unified_queryset) return { - 'files': list(unified_queryset), # [offset:limit] - 'num_files': num_files, + 'files': list(unified_queryset[offset:offset + self.limit]), + 'offset': next_offset, + 'search_query': '', } def search(self, request, folder_id): @@ -154,6 +159,7 @@ def search(self, request, folder_id): search_query = request.GET.get('q') if not search_query: return HttpResponseBadRequest("No search query provided.") + offset = int(request.GET.get('offset', 0)) starting_folder = FolderModel.objects.get(id=folder_id) search_realm = request.COOKIES.get('django-finder-search-realm') if search_realm == 'everywhere': @@ -168,8 +174,16 @@ def search(self, request, folder_id): 'name_lower__icontains': search_query, } unified_queryset = FileModel.objects.filter_unified(is_folder=False, **lookup) + if offset + self.limit < unified_queryset.count(): + next_offset = offset + self.limit + else: + next_offset = None annotate_unified_queryset(unified_queryset) - return {'files': list(unified_queryset)} + return { + 'files': list(unified_queryset), + 'offset': next_offset, + 'search_query': search_query, + } def upload(self, request, folder_id): """