Skip to content

Commit

Permalink
WiP on finder-browser component
Browse files Browse the repository at this point in the history
  • Loading branch information
jrief committed Oct 11, 2024
1 parent aa41597 commit fb62664
Show file tree
Hide file tree
Showing 15 changed files with 397 additions and 7 deletions.
179 changes: 179 additions & 0 deletions client/browser/FinderFileSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import React, {createRef, useEffect, useState} from 'react';
import {File, Folder} from "../finder/Item";
import ArrowDownIcon from '../icons/arrow-down.svg';
import ArrowRightIcon from '../icons/arrow-right.svg';
import EmptyIcon from '../icons/empty.svg';
import FolderIcon from '../icons/folder.svg';
import RootIcon from '../icons/root.svg';


function InodeList(props) {
const {folderId} = props;
const [isLoading, setLoading] = useState(false);
const [inodes, setInodes] = useState([]);
const [searchQuery, setSearchQuery] = useState(() => {
const params = new URLSearchParams(window.location.search);
return params.get('q');
});

useEffect(() => {
fetchInodes();
}, [searchQuery]);

async function fetchInodes() {
const params = new URLSearchParams({q: searchQuery});
const fetchInodesUrl = `${props.baseurl}${folderId}/fetch${searchQuery ? `?${params.toString()}` : ''}`;
setLoading(true);
const response = await fetch(fetchInodesUrl);
if (response.ok) {
const body = await response.json();
setInodes(body.inodes.map(inode => {
const elementRef = createRef();
return {...inode, elementRef};
}));
} else {
console.error(response);
}
setLoading(false);
}

function selectInode(event: PointerEvent, inode) {
if (inode.disabled)
return;
let modifier, selectedIndex = -1;
// simple click
if (inode.selected) {
modifier = f => ({...f, selected: false});
} else {
if (!(event.target as HTMLElement)?.classList.contains('inode-name')) {
// prevent selecting the inode when clicking on the name field to edit it
modifier = f => ({...f, selected: f.id === inode.id});
selectedIndex = inodes.findIndex(f => f.id === inode.id); // remember for an upcoming shift-click
} else {
modifier = f => f;
}
}
const modifiedInodes = inodes.map((f, k) => ({...modifier(f, k), cutted: false, copied: false}));
setInodes(modifiedInodes);
}

function deselectInodes() {
if (inodes.find(inode => inode.selected || inode.dragged)) {
setInodes(inodes.map(inode => ({...inode, selected: false, dragged: false})));
}
}

function renderInodes() {
if (isLoading)
return (<li className="status">{gettext("Loading...")}</li>);

if (inodes.length === 0) {
if (searchQuery)
return (<li className="status">{`No match while searching for “${searchQuery}”`}</li>);
return (<li className="status">{gettext("This folder is empty")}</li>);
}

return inodes.map(inode => inode.is_folder
? <Folder key={inode.id} {...inode} {...props} />
: <File key={inode.id} {...inode} {...props} />
);
}

function cssClasses() {
const classes = ['inode-list'];
if (isLoading)
classes.push('loading');
return classes.join(' ');
}

return (
<ul className={cssClasses()}>
{renderInodes()}
</ul>
);
}


function FolderEntry(props) {
const {folder, toggleOpen} = props;

if (folder.is_root) {
return (<span><RootIcon/></span>);
}

return (<>
<i onClick={toggleOpen}>{
(folder.num_subfolders === 0) ? <EmptyIcon/> : folder.is_open ? <ArrowDownIcon/> : <ArrowRightIcon/>
}</i>
<span><FolderIcon/>{folder.name}</span>
</>);
}


function FolderStructure(props) {
const {baseurl, folder, refreshStructure} = props;

async function fetchChildren() {
const response = await fetch(`${baseurl}fetch/${folder.id}?open`);
if (response.ok) {
const reply = await response.json();
folder.name = reply.name;
folder.num_subfolders = reply.num_subfolders;
folder.children = reply.children;
} else {
console.error(response);
}
}

async function toggleOpen() {
folder.is_open = !folder.is_open;
if (folder.is_open) {
if (folder.children === null) {
await fetchChildren();
} else {
await fetch(`${baseurl}open/${folder.id}`);
}
} else {
await fetch(`${baseurl}close/${folder.id}`);
}
refreshStructure();
}

return folder ? (
<li>
<FolderEntry folder={folder} toggleOpen={toggleOpen} />
{folder.is_open && (<ul>
{folder.children.map(child => (
<FolderStructure key={child.id} folder={child} baseurl={baseurl} refreshStructure={refreshStructure} />
))}
</ul>)}
</li>
) : null;
}


export default function FinderFileSelect(props) {
const [structure, setStructure] = useState({root_folder: null});

useEffect(() => {
getStructure();
}, []);

async function getStructure() {
const response = await fetch(`${props.baseurl}structure/${props.realm}`);
if (response.ok) {
setStructure(await response.json());
} else {
console.error(response);
}
}

return (
<ul className="folder-structure">
<FolderStructure baseurl={props.baseurl} folder={structure.root_folder} refreshStructure={() => {
setStructure({root_folder: structure.root_folder});
}}
/>
</ul>
);
}
21 changes: 21 additions & 0 deletions client/browserbuild.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {build} from 'esbuild';
import svgr from 'esbuild-plugin-svgr';
import parser from 'yargs-parser';
const buildOptions = parser(process.argv.slice(2), {
boolean: ['debug', 'minify'],
});

await build({
entryPoints: [
'client/finder-browser.ts',
],
bundle: true,
minify: buildOptions.minify,
sourcemap: buildOptions.debug,
outfile: 'finder/static/finder/js/browser.js',
format: 'esm',
jsx: 'automatic',
plugins: [svgr()],
loader: {'.svg': 'text', '.jsx': 'jsx' },
target: ['es2020', 'chrome84', 'firefox84', 'safari14', 'edge84']
}).catch(() => process.exit(1));
23 changes: 23 additions & 0 deletions client/finder-browser.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
.folder-structure {
ul ul {
padding-left: 1.5rem;
}
&, ul {
padding-left: 0;
list-style: none;
}

li {
i {
display: inline-block;
cursor: pointer;
width: 1.5rem;
}

svg {
height: 20px;
vertical-align: text-bottom;
margin-right: 0.5em;
}
}
}
9 changes: 9 additions & 0 deletions client/finder-browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import r2wc from '@r2wc/react-to-web-component';
import FinderFileSelect from 'browser/FinderFileSelect';


window.addEventListener('DOMContentLoaded', (event) => {
window.customElements.define('finder-file-select', r2wc(FinderFileSelect, {
props: {baseurl: 'string', realm: 'string'}}
));
});
3 changes: 3 additions & 0 deletions client/icons/arrow-down.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions client/icons/arrow-right.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions client/icons/empty.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions client/icons/folder.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Empty file added finder/api/__init__.py
Empty file.
35 changes: 35 additions & 0 deletions finder/api/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from django.urls import path
from django.views.i18n import JavaScriptCatalog

from finder.api.views import BrowserView


app_name = 'finder-api'
urlpatterns = [
path(
'structure/<slug:realm>',
BrowserView.as_view(action='structure'),
),
path(
'fetch/<uuid:folder_id>',
BrowserView.as_view(action='fetch'),
),
path(
'open/<uuid:folder_id>',
BrowserView.as_view(action='open'),
),
path(
'close/<uuid:folder_id>',
BrowserView.as_view(action='close'),
),
path(
'jsi18n/',
JavaScriptCatalog.as_view(packages=['finder']),
name="javascript-catalog",
),
path(
'',
BrowserView.as_view(),
name="base-url",
),
]
103 changes: 103 additions & 0 deletions finder/api/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
from django.contrib.sites.shortcuts import get_current_site
from django.db.models import Q
from django.http import JsonResponse, HttpResponseBadRequest, HttpResponseNotFound
from django.views import View

from finder.models.folder import FolderModel, RealmModel


class BrowserView(View):
"""
The view for web component <finder-browser>.
"""
action = None

def get(self, request, *args, **kwargs):
action = getattr(self, self.action, None)
if not callable(action):
return HttpResponseBadRequest(f"Action {self.action} not allowed.")
return action(request, *args, **kwargs)

def structure(self, request, realm):
def get_children(parent):
open_folders = request.session['finder.open_folders']
result = []
for child in FolderModel.objects.filter(parent=parent):
child_id = str(child.id)
if child_id in open_folders:
children = get_children(child)
else:
children = None
result.append({
'id': child_id,
'name': child.name,
'children': children,
'is_open': children is not None,
'num_subfolders': child.subfolders.count(),
})
return result

site = get_current_site(request)
try:
realm = RealmModel.objects.get(site=site, slug=realm)
except RealmModel.DoesNotExist:
return HttpResponseNotFound(f"Realm {realm} not found for {site.domain}.")
root_folder = FolderModel.objects.get_root_folder(realm)
request.session.setdefault('finder.open_folders', [])
return JsonResponse({
'root_folder': {
'id': str(root_folder.id),
'name': None, # the root folder has no readable name
'is_root': True,
'is_open': FolderModel.objects.filter(parent=root_folder).exists(),
'children': get_children(root_folder),
'num_subfolders': root_folder.subfolders.count(),
}
})

def fetch(self, request, folder_id):
queryset = FolderModel.objects.filter(Q(id=folder_id) | Q(parent_id=folder_id))
try:
folder = queryset.get(id=folder_id)
except FolderModel.DoesNotExist:
return HttpResponseNotFound(f"Folder {folder_id} not found.")
else:
children = queryset.filter(parent_id=folder_id)

folder_id = str(folder_id)
if 'open' in request.GET:
request.session.setdefault('finder.open_folders', [])
if folder_id not in request.session['finder.open_folders']:
request.session['finder.open_folders'].append(folder_id)
request.session.modified = True

return JsonResponse({
'id': folder.id,
'name': folder.name,
'children': [{
'id': str(child.id),
'name': child.name,
'children': None, # children may be fetched lazily
'num_subfolders': child.subfolders.count(),
} for child in children],
'num_subfolders': folder.subfolders.count(),
})

def open(self, request, folder_id):
folder_id = str(folder_id)
request.session.setdefault('finder.open_folders', [])
if folder_id not in request.session['finder.open_folders']:
request.session['finder.open_folders'].append(folder_id)
request.session.modified = True

return JsonResponse({})

def close(self, request, folder_id):
folder_id = str(folder_id)
try:
request.session['finder.open_folders'].remove(folder_id)
request.session.modified = True
except (KeyError, ValueError):
pass

return JsonResponse({})
Loading

0 comments on commit fb62664

Please sign in to comment.