diff --git a/airlock/static/assets/activity.js b/airlock/static/assets/activity.js new file mode 100644 index 00000000..d7139e90 --- /dev/null +++ b/airlock/static/assets/activity.js @@ -0,0 +1 @@ +window.initCustomTable(); diff --git a/airlock/static/assets/datatable.css b/airlock/static/assets/datatable.css new file mode 100644 index 00000000..63de4b19 --- /dev/null +++ b/airlock/static/assets/datatable.css @@ -0,0 +1,24 @@ +.datatable thead { + position: sticky; + top: 0; + text-align: left; + background-color: rgba(248, 250, 252); +} + +#customTable.datatable-table th:first-child, +#customTable.datatable-table td:first-child { + padding-inline-start: 1.25rem; + + button { + padding-inline-start: 0; + } +} + +#customTable.datatable-table th:last-child, +#customTable.datatable-table td:last-child { + padding-inline-end: 1.25rem; + + button { + padding-inline-end: 0; + } +} diff --git a/airlock/static/assets/file_browser/csv.js b/airlock/static/assets/file_browser/csv.js new file mode 100644 index 00000000..1ca6930e --- /dev/null +++ b/airlock/static/assets/file_browser/csv.js @@ -0,0 +1,17 @@ +const observer = new MutationObserver((mutations, obs) => { + const pageNumberEl = document.querySelector( + `[data-table-pagination="page-number"]` + ); + if (pageNumberEl.innerText !== "#") { + document.querySelector("#csvtable p.spinner").style.display = "none"; + document.querySelector("#csvtable table.datatable").style.display = "table"; + document.querySelector("#pagination-nav").classList.remove("hidden"); + obs.disconnect(); + return; + } +}); + +observer.observe(document, { + childList: true, + subtree: true, +}); diff --git a/airlock/static/assets/file_browser/index.css b/airlock/static/assets/file_browser/index.css new file mode 100644 index 00000000..51680f19 --- /dev/null +++ b/airlock/static/assets/file_browser/index.css @@ -0,0 +1,95 @@ +ul.root { + padding: 0.2rem; +} + +ul.tree { + list-style: none; + font-size: 95%; +} + +ul.tree details ul { + border-left: 1px dotted grey; + padding-left: 0.75rem; + margin-left: 0.5rem; +} + +.tree summary { + cursor: pointer; +} + +.tree li:has(> a.supporting) { + font-style: italic; +} + +.tree li:has(> a.withdrawn) { + font-style: italic; + text-decoration: line-through; +} + +.tree summary:has(> a.selected), +.tree li:has(> a.selected) { + background-color: lightblue; +} + +.tree summary:has(> a.invalid), +.tree li:has(> a.invalid) { + color: darkgray; +} + +.tree .selected { + font-weight: bold; + cursor: pointer; +} + +.tree .filegroup { + text-transform: uppercase; +} + +.tree summary:has(> a.filegroup) { + background-color: lightgrey; +} + +.tree a.directory { + background-repeat: no-repeat; + background-size: 1.4rem; + background-position: left 0 top 0; + padding-left: 1.3rem; +} +.tree summary { + padding-left: 0.25rem; +} + +.content { + width: 100%; + max-width: 100%; +} + +.content-scroller { + height: 75vh; + overflow: scroll; +} + +.browser { + display: grid; + gap: 1rem; + grid-template-columns: repeat(4, minmax(0, 1fr)); + min-height: 100%; + padding-inline: 1rem; +} + +.browser__files { + background-color: white; + box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, + rgba(0, 0, 0, 0.1) 0px 1px 3px 0px, rgba(0, 0, 0, 0.1) 0px 1px 2px -1px; + grid-column: 1 / 1; +} + +.browser__content { + grid-column: 2 / -1; + + > div { + display: flex; + flex-direction: column; + gap: 1rem; + } +} diff --git a/airlock/static/assets/file_browser/index.js b/airlock/static/assets/file_browser/index.js new file mode 100644 index 00000000..c4ed2b8b --- /dev/null +++ b/airlock/static/assets/file_browser/index.js @@ -0,0 +1,22 @@ +// keep the selected class up to date in the tree on the client side +function setTreeSelection(tree, event) { + // target here is the hx-get link that has been clicked on + + // remove class from currently selected node + tree.querySelector(".selected")?.classList.remove("selected"); + + let target = event.srcElement; + + // set current selected + target.classList.add("selected"); + // ensure parent details container is open, which means clicking on a directory will open containers. + target.closest("details").open = true; + + // if target link is a filegroup, ensure all child
are opened, to match server-side rendering of tree + if (target.classList.contains("filegroup")) { + target + .closest("li.tree") + .querySelectorAll("details") + .forEach((e) => (e.open = true)); + } +} diff --git a/airlock/static/assets/login.js b/airlock/static/assets/login.js new file mode 100644 index 00000000..77c8985d --- /dev/null +++ b/airlock/static/assets/login.js @@ -0,0 +1,4 @@ +function showSpinner() { + document.getElementById("login-button").classList.add("hidden"); + document.getElementById("spinner").classList.remove("hidden"); +} diff --git a/airlock/templates/activity.html b/airlock/templates/activity.html index 5c43ef59..395ab034 100644 --- a/airlock/templates/activity.html +++ b/airlock/templates/activity.html @@ -1,48 +1,44 @@ -{% load django_vite %} {% load airlock %} +{% load django_vite %} +{% load static %} - - + -{% #card title="Recent Activity" container=True %} - - - - - - - - - - - {% for log in activity %} +{% #card title="Recent activity" %} + {% if activity %} +
Time{% datatable_sort_icon %}
User{% datatable_sort_icon %}
Action{% datatable_sort_icon %}
Details{% datatable_sort_icon %}
+ - - - - + + + + - {% endfor %} - -
{{ log.created_at|date:'Y-m-d H:i' }}{{ log.user }}{{ log.description }} -
    - {% if log.path %}
  • path: {{ log.path }}
  • {% endif %} - {% for k,v in log.extra.items %} -
  • {{ k }}: {{ v }}
  • - {% endfor %} -
-
Time{% datatable_sort_icon %}
User{% datatable_sort_icon %}
Action{% datatable_sort_icon %}
Details{% datatable_sort_icon %}
- + + + {% for log in activity %} + + {{ log.created_at|date:'Y-m-d H:i' }} + {{ log.user }} + {{ log.description }} + + + + + {% endfor %} + + + {% else %} + {% #list_group %} + {% list_group_empty title="No activity" description="There has been no recent activity on this workspace" %} + {% /list_group %} + {% endif %} {% /card %} {% vite_asset "assets/src/scripts/components.js" %} - + diff --git a/airlock/templates/file_browser/csv.html b/airlock/templates/file_browser/csv.html index 03aa3627..1760a022 100644 --- a/airlock/templates/file_browser/csv.html +++ b/airlock/templates/file_browser/csv.html @@ -1,23 +1,14 @@ -{% load django_vite %} {% load airlock %} +{% load django_vite %} +{% load static %} - - - {% vite_hmr_client %} {% vite_asset "assets/src/scripts/base.js" %} {% vite_asset "assets/src/scripts/components.js" %} - + @@ -88,28 +79,7 @@ - - + diff --git a/airlock/templates/file_browser/directory.html b/airlock/templates/file_browser/directory.html index 5d2b7c7a..99058047 100644 --- a/airlock/templates/file_browser/directory.html +++ b/airlock/templates/file_browser/directory.html @@ -1,7 +1,7 @@ -{% #card title=path_item.name container=True %} +{% #card title=path_item.name %} {% #list_group %} {% if not path_item.children %} - {% list_group_empty icon=True title="Empty Directory" %} + {% list_group_empty icon=True title="This directory is empty" %} {% else %} {% for entry in path_item.children %} {% #list_group_item href=entry.url %} diff --git a/airlock/templates/file_browser/index.html b/airlock/templates/file_browser/index.html index 1639b995..6eced658 100644 --- a/airlock/templates/file_browser/index.html +++ b/airlock/templates/file_browser/index.html @@ -5,84 +5,18 @@ {% load django_htmx %} {% block extra_styles %} + {% endblock extra_styles %} -{% block full_width_content %} +{% block content %} +
+ {% article_header title=title %} - {% fragment as action_button %}
{% if context == "request" %} {% if is_author %} @@ -132,60 +66,34 @@ {% elif context == "request" or context == "workspace" %} {% #button type="link" href=workspace.get_requests_url variant="success" id="requests-workspace-button" %}All release requests for workspace{% /button %} {% endif %} -
- {% endfragment %} - - {% #card title=title custom_button=action_button %} - {% /card %} - -
- -
- {% #card %} -
    - {% include "file_browser/tree.html" with path=root.fake_parent %} -
- {% /card %} +
+{% endblock content %} +{% block full_width_content %} +
+
+
    + {% include "file_browser/tree.html" with path=root.fake_parent %} +
-
+
{% include "file_browser/contents.html" %}
- {% endblock full_width_content %} {% block extra_js %} {% django_htmx_script %} - - + {% endblock %} diff --git a/airlock/templates/file_browser/request.html b/airlock/templates/file_browser/request.html index 734ba6e4..46b60776 100644 --- a/airlock/templates/file_browser/request.html +++ b/airlock/templates/file_browser/request.html @@ -1,12 +1,15 @@ -{% #card title="Request "|add:release_request.id container=True %} - - - {% #description_item title="Status:" %}{{ release_request.status.name }}{% /description_item %} - - {% #description_item title="Files requested for release:" %}{{ release_request.output_files_set|length }}{% /description_item %} - - {% #description_item title="Supporting files not for release:" %}{{ release_request.supporting_files_count }}{% /description_item %} - +{% #card title="Request: "|add:release_request.id %} + {% #description_list %} + {% #description_item title="Status" %} + {{ release_request.status.name }} + {% /description_item %} + {% #description_item title="Files requested for release" %} + {{ release_request.output_files_set|length }} + {% /description_item %} + {% #description_item title="Supporting files not for release" %} + {{ release_request.supporting_files_count }} + {% /description_item %} + {% /description_list %} {% /card %} {% include "activity.html" %} diff --git a/airlock/templates/file_browser/workspace.html b/airlock/templates/file_browser/workspace.html index 162a41a8..81fbd3a4 100644 --- a/airlock/templates/file_browser/workspace.html +++ b/airlock/templates/file_browser/workspace.html @@ -1,7 +1,9 @@ -{% #card title="Workspace "|add:workspace.name container=True %} - - Project: {{ project }} - +{% #card title=workspace.name %} + {% #description_list %} + {% #description_item title="Project" %} + {{ project }} + {% /description_item %} + {% /description_list %} {% /card %} {% include "activity.html" %} diff --git a/airlock/templates/login.html b/airlock/templates/login.html index d40cc288..30e8f383 100644 --- a/airlock/templates/login.html +++ b/airlock/templates/login.html @@ -1,5 +1,7 @@ {% extends "base.html" %} +{% load static %} + {% block metatitle %}Login | OpenSAFELY Airlock{% endblock metatitle %} {% block content %} @@ -44,12 +46,5 @@ {% block extra_js %} - - - + {% endblock %} diff --git a/airlock/templates/requests_for_workspace.html b/airlock/templates/requests_for_workspace.html index 6c99344d..82c87a62 100644 --- a/airlock/templates/requests_for_workspace.html +++ b/airlock/templates/requests_for_workspace.html @@ -6,6 +6,8 @@ {% #list_group id="requests-workspace" %} {% for request in requests_for_workspace %} {% #list_group_item href=request.get_url %}{{ request.workspace }} ({{ request.author }}): {{ request.status.name }} {% /list_group_item %} + {% empty %} + {% list_group_empty title="No requests" description="There are no requests in this workspace" %} {% endfor %} {% /list_group %} {% /card %} diff --git a/tests/integration/views/test_request.py b/tests/integration/views/test_request.py index f4e9759e..1baa20c2 100644 --- a/tests/integration/views/test_request.py +++ b/tests/integration/views/test_request.py @@ -67,10 +67,10 @@ def test_request_view_root_summary(airlock_client): assert response.status_code == 200 assert "PENDING" in response.rendered_content # output files - assert ">2<" in response.rendered_content + assert ">\n 2\n <" in response.rendered_content # supporting files - assert ">1<" in response.rendered_content - assert "Recent Activity" in response.rendered_content + assert ">\n 1\n <" in response.rendered_content + assert "Recent activity" in response.rendered_content assert "audit_user" in response.rendered_content assert "Created request" in response.rendered_content @@ -89,7 +89,7 @@ def test_request_view_root_group(airlock_client): response = airlock_client.get(f"/requests/view/{release_request.id}/group1/") assert response.status_code == 200 - assert "Recent Activity" in response.rendered_content + assert "Recent activity" in response.rendered_content assert "audit_user" in response.rendered_content assert "Added file" in response.rendered_content @@ -538,6 +538,16 @@ def test_request_withdraw_not_author(airlock_client): assert persisted_request.status == RequestStatus.PENDING +def test_empty_requests_for_workspace(airlock_client): + airlock_client.login(workspaces=["test1"]) + + response = airlock_client.get("/requests/workspace/test1") + + response.render() + assert response.status_code == 200 + assert "There are no requests in this workspace" in response.rendered_content + + def test_requests_for_workspace(airlock_client): airlock_client.login(workspaces=["test1"]) author1 = factories.create_user("author1", ["test1"], False) diff --git a/tests/integration/views/test_workspace.py b/tests/integration/views/test_workspace.py index a7423374..b0c1bf38 100644 --- a/tests/integration/views/test_workspace.py +++ b/tests/integration/views/test_workspace.py @@ -31,7 +31,7 @@ def test_workspace_view_summary(airlock_client): assert "file.txt" in response.rendered_content assert "release-request-button" not in response.rendered_content assert "TESTPROJECT" in response.rendered_content - assert "Recent Activity" in response.rendered_content + assert "Recent activity" in response.rendered_content assert "audit_user" in response.rendered_content assert "Created request" in response.rendered_content @@ -71,7 +71,7 @@ def test_workspace_view_with_empty_directory(airlock_client): (workspace.root() / "some_dir").mkdir() response = airlock_client.get("/workspaces/view/workspace/some_dir/") assert response.status_code == 200 - assert "Empty Directory" in response.rendered_content + assert "This directory is empty" in response.rendered_content def test_workspace_view_with_file(airlock_client):