Skip to content

Commit 8584463

Browse files
committed
Add tree base navigation UI
- minimal functional implementation, not pretty - uses <details>, so no js needed - can switch at run time to test, but defaults to new UI In order for the template to be able to know if the current item in the tree is, or is a parent of, the currently selected path, I added the ability to designate a currently selected path to PathItem, as we can't express that logic cleanly in the DTL. Note: supporting both UIs behind a feature flag intended to be short lived, so that code should be deleted soon. Fixes #94
1 parent 8014b59 commit 8584463

File tree

7 files changed

+190
-30
lines changed

7 files changed

+190
-30
lines changed

airlock/file_browser_api.py

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ class PathItem:
2121
container: AirlockContainer
2222
relpath: pathlib.Path
2323

24+
# The currently selected view path in this container. Used to calculate if
25+
# how relpath relates to it, i.e. if relpath *is* the currently selected
26+
# path, or is one of its parents.
27+
selected: pathlib.Path = None
28+
2429
def __post_init__(self):
2530
# ensure relpath is a Path
2631
object.__setattr__(self, "relpath", pathlib.Path(self.relpath))
@@ -45,14 +50,19 @@ def url(self):
4550

4651
def parent(self):
4752
if self.relpath.parents:
48-
return PathItem(self.container, self.relpath.parent)
53+
return PathItem(self.container, self.relpath.parent, selected=self.selected)
4954

5055
def children(self):
56+
if not self.is_directory():
57+
return []
5158
root = self.container.root()
52-
return [
53-
PathItem(self.container, child.relative_to(root))
59+
children = [
60+
PathItem(self.container, child.relative_to(root), selected=self.selected)
5461
for child in self._absolute_path().iterdir()
5562
]
63+
# directories first, then alphabetical, aks what windows does
64+
children.sort(key=lambda p: (not p.is_directory(), p.relpath.name))
65+
return children
5666

5767
def siblings(self):
5868
if not self.relpath.parents:
@@ -63,10 +73,12 @@ def siblings(self):
6373
def contents(self):
6474
return self._absolute_path().read_text()
6575

66-
@property
6776
def suffix(self):
6877
return self.relpath.suffix
6978

79+
def file_type(self):
80+
return self.suffix().lstrip(".")
81+
7082
def breadcrumbs(self):
7183
item = self
7284
crumbs = [item]
@@ -79,3 +91,32 @@ def breadcrumbs(self):
7991

8092
crumbs.reverse()
8193
return crumbs
94+
95+
def html_classes(self):
96+
"""Semantic html classes for this PathItem.
97+
98+
Currently, only "selected" is used, but it made sense to be able to
99+
distinguish file/dirs, and maybe even file types, in the UI, in case we
100+
need to.
101+
"""
102+
classes = []
103+
104+
if self.is_directory():
105+
classes.append("dir")
106+
else:
107+
classes.append("file")
108+
classes.append(self.file_type())
109+
110+
if self.is_selected():
111+
classes.append("selected")
112+
113+
return " ".join(classes)
114+
115+
def is_selected(self):
116+
return self.relpath == self.selected
117+
118+
def is_on_selected_path(self):
119+
return self.relpath in self.selected.parents
120+
121+
def is_open(self):
122+
return self.is_selected() or self.is_on_selected_path()

airlock/settings.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,3 +244,6 @@ def run_connection_init_queries(*, connection, **kwargs):
244244
messages.WARNING: "alert-warning",
245245
messages.ERROR: "alert-danger",
246246
}
247+
248+
# Temporary UI featureflag
249+
TREE = True

airlock/templates/file_browser/index.html

Lines changed: 69 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,51 @@
22

33
{% load django_vite %}
44

5+
{% block extra_styles %}
6+
<style>
7+
8+
/* tree styles */
9+
ul.tree {
10+
list-style: none;
11+
12+
details ul {
13+
border-left: 1px dotted grey;
14+
padding-left: 0.25rem;
15+
margin-left: 0.25rem;
16+
}
17+
}
18+
19+
20+
.tree summary {
21+
cursor: pointer;
22+
}
23+
24+
.tree .selected {
25+
font-weight: bold;
26+
color: green;
27+
background: rgba(0, 255, 0, 0.1);
28+
cursor: pointer;
29+
}
30+
31+
32+
/* ths attempt at a nice selectedhighlight doesn't quite work
33+
.tree li {
34+
position: relative;
35+
}
36+
37+
&::before {
38+
background: rgba(0, 255, 0, 0.1);
39+
content: "";
40+
position: absolute;
41+
left: -100vw;
42+
height: 100%;
43+
width: 100vw;
44+
}
45+
*/
46+
47+
</style>
48+
{% endblock extra_styles %}
49+
550
{% block full_width_content %}
651

752
{% fragment as action_button %}
@@ -50,25 +95,32 @@
5095
<div style="flex-basis: 25%">
5196

5297
{% #card %}
53-
{% #list_group %}
54-
{% if not path_item.parent %}
55-
{% list_group_empty icon=True title=context|title|add:" Root" %}
56-
{% else %}
57-
{% #list_group_item href=path_item.parent.url %}
58-
↰ ..
59-
{% /list_group_item %}
60-
{% for entry in path_item.siblings %}
61-
{% #list_group_item href=entry.url %}
62-
{{ entry.name}}
63-
{% if entry.is_directory %}
64-
{% icon_folder_outline class="h-6 w-6 text-slate-600 inline" %}
65-
{% endif %}
98+
{% if tree %}
99+
<ul class="tree root">
100+
{% include "file_browser/tree.html" with path=root %}
101+
</ul>
102+
{% else %}
103+
{% #list_group %}
104+
{% if not path_item.parent %}
105+
{% list_group_empty icon=True title=context|title|add:" Root" %}
106+
{% else %}
107+
{% #list_group_item href=path_item.parent.url %}
108+
↰ ..
66109
{% /list_group_item %}
67-
{% endfor %}
68-
{% endif %}
69-
{% /list_group %}
110+
{% for entry in path_item.siblings %}
111+
{% #list_group_item href=entry.url %}
112+
{{ entry.name}}
113+
{% if entry.is_directory %}
114+
{% icon_folder_outline class="h-6 w-6 text-slate-600 inline" %}
115+
{% endif %}
116+
{% /list_group_item %}
117+
{% endfor %}
118+
{% endif %}
119+
{% /list_group %}
120+
{% endif %}
121+
70122

71-
{% #card_footer no_container=False %}
123+
{% #card_footer no_container=False %}
72124
{% if context == "request" %}
73125
<div class="px-4">
74126
{% #button variant="primary-outline" type="button" data-modal="viewFileGroups" %}
@@ -92,7 +144,6 @@
92144
{% /modal %}
93145
{% endif %}
94146
{% /card_footer %}
95-
96147
{% /card %}
97148

98149
</div>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{% for child in path.children %}
2+
<li class="tree ">
3+
{% if child.is_directory %}
4+
<details {% if child.is_open %}open{% endif %}>
5+
<summary>
6+
<a class="{{ child.html_classes }}" href="{{ child.url }}">{{ child.name }}</a>
7+
</summary>
8+
{% if child.children %}
9+
<ul class="tree">
10+
{% include "file_browser/tree.html" with path=child %}
11+
</ul>
12+
{% endif %}
13+
</details>
14+
{% else %}
15+
<a class="{{ child.html_classes }}" {% if not child.is_selected %}href="{{ child.url }}"{% endif %}>{{ child.name }}</a>
16+
{% endif %}
17+
</li>
18+
{% endfor %}

airlock/views.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,23 @@ def workspace_index(request):
118118
return TemplateResponse(request, "workspaces.html", {"workspaces": workspaces})
119119

120120

121+
def use_tree_ui(request):
122+
"""Quick hack to be able to dynamically switch ui options."""
123+
tree = request.session.get("tree", settings.TREE)
124+
# hack to switch UI dynamically
125+
if "tree" in request.GET: # pragma: nocover
126+
tree = request.GET["tree"].lower() == "true"
127+
128+
request.session["tree"] = tree
129+
return tree
130+
131+
121132
def workspace_view(request, workspace_name: str, path: str = ""):
122133
workspace = validate_workspace(request.user, workspace_name)
123134

124-
path_item = PathItem(workspace, path)
135+
relpath = Path(path)
136+
root = PathItem(workspace, Path("."), selected=relpath)
137+
path_item = PathItem(workspace, path, selected=relpath)
125138

126139
if not path_item.exists():
127140
raise Http404()
@@ -135,6 +148,7 @@ def workspace_view(request, workspace_name: str, path: str = ""):
135148
"file_browser/index.html",
136149
{
137150
"workspace": workspace,
151+
"root": root,
138152
"path_item": path_item,
139153
"context": "workspace",
140154
"title": f"Files for workspace {workspace_name}",
@@ -145,6 +159,7 @@ def workspace_view(request, workspace_name: str, path: str = ""):
145159
"form": AddFileForm(
146160
release_request=api.get_current_request(workspace_name, request.user)
147161
),
162+
"tree": use_tree_ui(request),
148163
},
149164
)
150165

@@ -200,7 +215,9 @@ def request_index(request):
200215
def request_view(request, request_id: str, path: str = ""):
201216
release_request = validate_release_request(request.user, request_id)
202217

203-
path_item = PathItem(release_request, path)
218+
relpath = Path(path)
219+
root = PathItem(release_request, Path("."), selected=relpath)
220+
path_item = PathItem(release_request, path, selected=relpath)
204221

205222
if not path_item.exists():
206223
raise Http404()
@@ -230,6 +247,7 @@ def request_view(request, request_id: str, path: str = ""):
230247
context = {
231248
"workspace": api.get_workspace(release_request.workspace),
232249
"release_request": release_request,
250+
"root": root,
233251
"path_item": path_item,
234252
"context": "request",
235253
"title": f"Request for {release_request.workspace} by {release_request.author}",
@@ -239,6 +257,7 @@ def request_view(request, request_id: str, path: str = ""):
239257
"request_submit_url": request_submit_url,
240258
"request_reject_url": request_reject_url,
241259
"release_files_url": release_files_url,
260+
"tree": use_tree_ui(request),
242261
}
243262

244263
return TemplateResponse(request, "file_browser/index.html", context)

tests/integration/test_views.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,13 @@ def client_with_permission(client_with_user):
3434
yield client_with_user(output_checker)
3535

3636

37-
def test_workspace_view(client_with_permission):
37+
# temporary fixture to test both UI options until we decide on one.
38+
@pytest.fixture(params=[True, False])
39+
def ui_options(request, settings):
40+
settings.TREE = request.param
41+
42+
43+
def test_workspace_view(client_with_permission, ui_options):
3844
factories.write_workspace_file("workspace", "file.txt")
3945

4046
response = client_with_permission.get("/workspaces/view/workspace/")
@@ -46,13 +52,13 @@ def test_workspace_does_not_exist(client_with_permission):
4652
assert response.status_code == 404
4753

4854

49-
def test_workspace_view_with_directory(client_with_permission):
55+
def test_workspace_view_with_directory(client_with_permission, ui_options):
5056
factories.write_workspace_file("workspace", "some_dir/file.txt")
5157
response = client_with_permission.get("/workspaces/view/workspace/some_dir/")
5258
assert "file.txt" in response.rendered_content
5359

5460

55-
def test_workspace_view_with_file(client_with_permission):
61+
def test_workspace_view_with_file(client_with_permission, ui_options):
5662
factories.write_workspace_file("workspace", "file.txt", "foobar")
5763
response = client_with_permission.get("/workspaces/view/workspace/file.txt")
5864
assert "foobar" in response.rendered_content
@@ -269,7 +275,7 @@ def test_request_index_no_user(client):
269275
assert response.status_code == 302
270276

271277

272-
def test_request_view_index(client_with_permission):
278+
def test_request_view_index(client_with_permission, ui_options):
273279
release_request = factories.create_release_request(
274280
"workspace", client_with_permission.user
275281
)
@@ -288,7 +294,7 @@ def test_request_id_does_not_exist(client_with_permission):
288294
assert response.status_code == 404
289295

290296

291-
def test_request_view_with_directory(client_with_permission):
297+
def test_request_view_with_directory(client_with_permission, ui_options):
292298
release_request = factories.create_release_request("workspace")
293299
factories.write_request_file(release_request, "some_dir/file.txt")
294300
response = client_with_permission.get(
@@ -297,7 +303,7 @@ def test_request_view_with_directory(client_with_permission):
297303
assert "file.txt" in response.rendered_content
298304

299305

300-
def test_request_view_with_file(client_with_permission):
306+
def test_request_view_with_file(client_with_permission, ui_options):
301307
release_request = factories.create_release_request("workspace")
302308
factories.write_request_file(release_request, "file.txt", "foobar")
303309
response = client_with_permission.get(

tests/unit/test_file_browser_api.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ def test_parent(container, path, parent_path):
103103
"some_dir",
104104
["some_dir/file_a.txt", "some_dir/file_b.txt"],
105105
),
106+
(
107+
"some_dir/file_a.txt",
108+
[],
109+
),
106110
],
107111
)
108112
def test_children(container, path, child_paths):
@@ -151,3 +155,21 @@ def test_breadcrumbs(container):
151155
PathItem(container, "foo/bar"),
152156
PathItem(container, "foo/bar/baz"),
153157
]
158+
159+
160+
def test_selection_logic(container):
161+
selected = Path("some_dir/file_a.txt")
162+
163+
selected_item = PathItem(container, selected, selected)
164+
assert selected_item.is_selected()
165+
assert selected_item.is_open()
166+
167+
parent_item = PathItem(container, "some_dir", selected)
168+
assert not parent_item.is_selected()
169+
assert parent_item.is_on_selected_path()
170+
assert parent_item.is_open()
171+
172+
other_item = PathItem(container, "other_dir", selected)
173+
assert not other_item.is_selected()
174+
assert not other_item.is_on_selected_path()
175+
assert not other_item.is_open()

0 commit comments

Comments
 (0)