Skip to content

Commit aef59c5

Browse files
authored
Merge pull request #94 from opensafely-core/tree
Add tree base navigation UI
2 parents 8014b59 + 8584463 commit aef59c5

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)