From 6a15778b0439815b62b35a616808d69112bdc0e9 Mon Sep 17 00:00:00 2001 From: Nathan Franklin Date: Mon, 22 Apr 2024 15:29:33 -0500 Subject: [PATCH 1/3] task/DES-2707: add filemeta app (#1194) * Add filemeta app * Add AuthenticatedApiView to main from https://github.com/DesignSafe-CI/portal/pull/1177/commits/f19997bfb90d7751c6bc4847915c448c5f22aa91#diff-f1bbb364f4c92d6f513be2ef3c46a1a4e14453cadd8b18ef25bc124125c0d927 * Update designsafe/apps/api/filemeta/models.py Co-authored-by: Jake Rosenberg * Add app to INSTALLED_APPS * Rename data to value * Fix url's by path * Add access check * Add unit tests * Extend tests * Fix pylint issues * Fix black formatting issues * Add migration * Rework model so all info is in json value column * Add test * Update docstring for publications * Add todo * Add mising migration --------- Co-authored-by: Jake Rosenberg --- designsafe/apps/api/filemeta/__init__.py | 0 designsafe/apps/api/filemeta/apps.py | 10 ++ .../api/filemeta/migrations/0001_initial.py | 36 ++++ .../migrations/0002_auto_20240418_1650.py | 21 +++ .../apps/api/filemeta/migrations/__init__.py | 0 designsafe/apps/api/filemeta/models.py | 67 ++++++++ designsafe/apps/api/filemeta/tests.py | 161 ++++++++++++++++++ designsafe/apps/api/filemeta/urls.py | 9 + designsafe/apps/api/filemeta/views.py | 100 +++++++++++ designsafe/apps/api/publications_v2/apps.py | 6 +- designsafe/apps/api/urls.py | 2 + designsafe/apps/api/views.py | 12 +- designsafe/settings/common_settings.py | 1 + designsafe/settings/test_settings.py | 1 + 14 files changed, 422 insertions(+), 4 deletions(-) create mode 100644 designsafe/apps/api/filemeta/__init__.py create mode 100644 designsafe/apps/api/filemeta/apps.py create mode 100644 designsafe/apps/api/filemeta/migrations/0001_initial.py create mode 100644 designsafe/apps/api/filemeta/migrations/0002_auto_20240418_1650.py create mode 100644 designsafe/apps/api/filemeta/migrations/__init__.py create mode 100644 designsafe/apps/api/filemeta/models.py create mode 100644 designsafe/apps/api/filemeta/tests.py create mode 100644 designsafe/apps/api/filemeta/urls.py create mode 100644 designsafe/apps/api/filemeta/views.py diff --git a/designsafe/apps/api/filemeta/__init__.py b/designsafe/apps/api/filemeta/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/designsafe/apps/api/filemeta/apps.py b/designsafe/apps/api/filemeta/apps.py new file mode 100644 index 0000000000..9fd0be8ae0 --- /dev/null +++ b/designsafe/apps/api/filemeta/apps.py @@ -0,0 +1,10 @@ +"""File Meta app config""" +from django.apps import AppConfig + + +class FileMetaAppConfig(AppConfig): + """App config for File Meta""" + + name = "designsafe.apps.api.filemeta" + label = "filemeta_api" + verbose_name = "Designsafe File Meta" diff --git a/designsafe/apps/api/filemeta/migrations/0001_initial.py b/designsafe/apps/api/filemeta/migrations/0001_initial.py new file mode 100644 index 0000000000..4b35130b5d --- /dev/null +++ b/designsafe/apps/api/filemeta/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.6 on 2024-04-18 16:44 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="FileMetaModel", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("value", models.JSONField()), + ("created", models.DateTimeField(default=django.utils.timezone.now)), + ("last_updated", models.DateTimeField(auto_now=True)), + ], + options={ + "indexes": [ + models.Index(models.F("value__system"), name="idx_value_system"), + models.Index(models.F("value__path"), name="idx_value_path"), + ], + }, + ), + ] diff --git a/designsafe/apps/api/filemeta/migrations/0002_auto_20240418_1650.py b/designsafe/apps/api/filemeta/migrations/0002_auto_20240418_1650.py new file mode 100644 index 0000000000..382f3f0b93 --- /dev/null +++ b/designsafe/apps/api/filemeta/migrations/0002_auto_20240418_1650.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.6 on 2024-04-18 16:50 +# Custom migration to handle unique constraint of system/path in "value" +from django.db import migrations, models +import django.db.models.expressions as expressions + + +class Migration(migrations.Migration): + dependencies = [ + ("filemeta_api", "0001_initial"), + ] + + operations = [ + migrations.AddConstraint( + model_name="filemetamodel", + constraint=models.UniqueConstraint( + expressions.F("value__system"), + expressions.F("value__path"), + name="unique_system_path", + ), + ), + ] diff --git a/designsafe/apps/api/filemeta/migrations/__init__.py b/designsafe/apps/api/filemeta/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/designsafe/apps/api/filemeta/models.py b/designsafe/apps/api/filemeta/models.py new file mode 100644 index 0000000000..e9a9e41e0e --- /dev/null +++ b/designsafe/apps/api/filemeta/models.py @@ -0,0 +1,67 @@ +"""File Meta model""" + +from django.db import models, transaction +from django.db.models import Q +from django.utils import timezone + + +class FileMetaModel(models.Model): + """Model for File Meta""" + + value = models.JSONField() # 'path' and 'system' keys are always in value + created = models.DateTimeField(default=timezone.now) + last_updated = models.DateTimeField(auto_now=True) + + class Meta: + indexes = [ + models.Index( + models.F( + "value__system" + ), # Functional index on 'system' within 'value' + name="idx_value_system", + ), + models.Index( + models.F("value__path"), # Functional index on 'path' within 'value' + name="idx_value_path", + ), + ] + + def __str__(self): + return f"{self.value}" + + @classmethod + def create_or_update_file_meta(cls, value): + """ + Create or update a FileMetaModel instance based on the provided value dict containing 'system' and 'path'. + + Parameters: + - value (dict): A dictionary containing file metadata including required 'system' and 'path'. + + Returns: + - tuple (instance, created): The FileMetaModel instance and a boolean indicating if it was created (True) or updated (False). + """ + system = value.get("system") + path = value.get("path") + + # Use a transaction to ensure atomicity + with transaction.atomic(): + try: + file_meta = FileMetaModel.objects.select_for_update().get( + Q(value__system=system) & Q(value__path=path) + ) + file_meta.value = value + file_meta.save() + return file_meta, False + except FileMetaModel.DoesNotExist: + file_meta = FileMetaModel.objects.create(value=value) + return file_meta, True + + @classmethod + def get_by_path_and_system(cls, system, path): + """ + Retrieve file metadata by 'system' and 'path'. + + Raises: + - DoesNotExist: if file metadata entry not found + """ + return cls.objects.get(value__system=system, value__path=path) diff --git a/designsafe/apps/api/filemeta/tests.py b/designsafe/apps/api/filemeta/tests.py new file mode 100644 index 0000000000..b80fedafa8 --- /dev/null +++ b/designsafe/apps/api/filemeta/tests.py @@ -0,0 +1,161 @@ +import pytest +import json +from django.db import IntegrityError +from django.test import Client +from designsafe.apps.api.filemeta.models import FileMetaModel + + +@pytest.fixture +def filemeta_value_mock(): + value = {"system": "project-1234", "path": "/test/path.txt"} + return value + + +@pytest.fixture +def filemeta_db_mock(filemeta_value_mock): + system_id = filemeta_value_mock["system"] + path = filemeta_value_mock["path"] + value = filemeta_value_mock + file_meta_obj = FileMetaModel.objects.create(value=value) + return system_id, path, file_meta_obj + + +@pytest.fixture +def mock_access_success(mocker): + """Fixture to mock the listing function to always succeed.""" + mocker.patch("designsafe.apps.api.filemeta.views.listing") + + +@pytest.fixture +def mock_access_failure(mocker): + """Fixture to mock the listing function to always raise an exception.""" + mocker.patch( + "designsafe.apps.api.filemeta.views.listing", + side_effect=Exception("Access Denied"), + ) + + +@pytest.mark.django_db +def test_database_constraint(filemeta_value_mock): + FileMetaModel.objects.create(value=filemeta_value_mock) + with pytest.raises(IntegrityError) as excinfo: + FileMetaModel.objects.create(value=filemeta_value_mock) + assert "Unique" in str(excinfo.value) + + +@pytest.mark.django_db +def test_database_get_by_path_system(filemeta_db_mock, filemeta_value_mock): + FileMetaModel.get_by_path_and_system( + system=filemeta_value_mock["system"], path=filemeta_value_mock["path"] + ) + + with pytest.raises(FileMetaModel.DoesNotExist): + FileMetaModel.get_by_path_and_system(system="foo", path="bar/baz.txt") + + +@pytest.mark.django_db +def test_database_constraint(filemeta_value_mock): + FileMetaModel.objects.create(value=filemeta_value_mock) + with pytest.raises(IntegrityError) as excinfo: + FileMetaModel.objects.create(value=filemeta_value_mock) + assert "Unique" in str(excinfo.value) + + +@pytest.mark.django_db +def test_get_file_meta_unauthenticated(client, filemeta_db_mock, mock_access_success): + system_id, path, file_meta = filemeta_db_mock + response = client.get(f"/api/filemeta/{system_id}/{path}") + assert response.status_code == 401 + + +@pytest.mark.django_db +def test_get_file_meta( + client, authenticated_user, filemeta_db_mock, mock_access_success +): + system_id, path, file_meta = filemeta_db_mock + response = client.get(f"/api/filemeta/{system_id}/{path}") + assert response.status_code == 200 + + assert response.json() == { + "value": file_meta.value, + "name": "designsafe.file", + "lastUpdated": file_meta.last_updated.isoformat( + timespec="milliseconds" + ).replace("+00:00", "Z"), + } + + +@pytest.mark.django_db +def test_create_file_meta_no_access( + client, authenticated_user, filemeta_value_mock, mock_access_failure +): + response = client.post( + "/api/filemeta/", + data=json.dumps(filemeta_value_mock), + content_type="application/json", + ) + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_create_file_meta_unauthenticated(client, filemeta_value_mock): + response = client.post( + "/api/filemeta/", + data=json.dumps(filemeta_value_mock), + content_type="application/json", + ) + assert response.status_code == 401 + + +@pytest.mark.django_db +def test_create_file_meta( + client, authenticated_user, filemeta_value_mock, mock_access_success +): + response = client.post( + "/api/filemeta/", + data=json.dumps(filemeta_value_mock), + content_type="application/json", + ) + assert response.status_code == 200 + + file_meta = FileMetaModel.objects.first() + assert file_meta.value == filemeta_value_mock + + +@pytest.mark.django_db +def test_create_file_meta_update_existing_entry( + client, + authenticated_user, + filemeta_db_mock, + filemeta_value_mock, + mock_access_success, +): + updated_value = {**filemeta_value_mock, "new_key": "new_value"} + + response = client.post( + "/api/filemeta/", + data=json.dumps(updated_value), + content_type="application/json", + ) + assert response.status_code == 200 + + file_meta = FileMetaModel.objects.first() + assert file_meta.value == updated_value + + +@pytest.mark.django_db +def test_create_file_metadata_missing_system_or_path( + client, + authenticated_user, + filemeta_db_mock, + filemeta_value_mock, + mock_access_success, +): + value_missing_system_path = {"foo": "bar"} + + response = client.post( + "/api/filemeta/", + data=json.dumps(value_missing_system_path), + content_type="application/json", + ) + assert response.status_code == 400 diff --git a/designsafe/apps/api/filemeta/urls.py b/designsafe/apps/api/filemeta/urls.py new file mode 100644 index 0000000000..a666913589 --- /dev/null +++ b/designsafe/apps/api/filemeta/urls.py @@ -0,0 +1,9 @@ +"""File Meta API routes""" + +from django.urls import path +from .views import FileMetaView, CreateFileMetaView + +urlpatterns = [ + path("/", FileMetaView.as_view(), name="filemeta-get"), + path("", CreateFileMetaView.as_view(), name="filemeta-create"), +] diff --git a/designsafe/apps/api/filemeta/views.py b/designsafe/apps/api/filemeta/views.py new file mode 100644 index 0000000000..d15dd49dd3 --- /dev/null +++ b/designsafe/apps/api/filemeta/views.py @@ -0,0 +1,100 @@ +"""File Meta view""" +import logging +import json +from django.http import JsonResponse, HttpRequest +from designsafe.apps.api.datafiles.operations.agave_operations import listing +from designsafe.apps.api.exceptions import ApiException +from designsafe.apps.api.filemeta.models import FileMetaModel +from designsafe.apps.api.views import AuthenticatedApiView + + +logger = logging.getLogger(__name__) + + +def check_access(request, system_id: str, path: str, check_for_writable_access=False): + """ + Check if the user has access to a specific system and path. + + This function utilizes the listing functionality to verify access. Writable access is only given for files + in DS project systems. + + Raises: + - ApiException: If the user is forbidden from accessing or modifying the metadata. + """ + + if check_for_writable_access and not system_id.startswith("project-"): + error_msg = f"Metadata updates are not allowed on non-project systems (system={system_id})." + logger.error(error_msg) + raise ApiException(error_msg, status=403) + + try: + # TODO_V3 update to use renamed (i.e. "tapis") client + listing(request.user.agave_oauth.client, system_id, path) + except Exception as exc: # pylint:disable=broad-exception-caught + logger.error( + f"user cannot access any related metadata as listing failed for {system_id}/{path} with error {str(exc)}." + ) + raise ApiException("User forbidden to access metadata", status=403) from exc + + +# TODO_V3 update to allow JWT access DES-2706: https://github.com/DesignSafe-CI/portal/pull/1192 +class FileMetaView(AuthenticatedApiView): + """View for creating and getting file metadata""" + + def get(self, request: HttpRequest, system_id: str, path: str): + """Return metadata for system_id/path + + If no metadata for system_id and path, then empty dict is returned + """ + check_access(request, system_id, path) + + result = {} + try: + logger.debug(f"Get file metadata. system:{system_id} path:{path}") + file_meta = FileMetaModel.get_by_path_and_system( + system=system_id, path=path + ) + result = { + "value": file_meta.value, + "lastUpdated": file_meta.last_updated, + "name": "designsafe.file", + } + except FileMetaModel.DoesNotExist: + pass + + return JsonResponse(result, safe=False) + + +# TODO_V3 update to allow JWT access DES-2706: https://github.com/DesignSafe-CI/portal/pull/1192 +class CreateFileMetaView(AuthenticatedApiView): + """View for creating (and updating) file metadata""" + + def post(self, request: HttpRequest): + """Create metadata for system_id/path.""" + + value = json.loads(request.body) + if "system" not in value or "path" not in value: + logger.error( + f"Unable to create or update file metadata as system and path not in payload: {value}" + ) + raise ApiException("System and path are required in payload", status=400) + + system_id = value["system"] + path = value["path"] + + check_access(request, system_id, path, check_for_writable_access=True) + + try: + logger.info( + f"Creating or updating file metadata. system:{system_id} path:{path}" + ) + + FileMetaModel.create_or_update_file_meta(value) + return JsonResponse({"result": "OK"}) + except Exception as exc: + logger.exception( + f"Unable to create or update file metadata: {system_id}/{path}" + ) + raise ApiException( + "Unable to create or update file metadata", status=500 + ) from exc diff --git a/designsafe/apps/api/publications_v2/apps.py b/designsafe/apps/api/publications_v2/apps.py index 6745d5e052..512376f288 100644 --- a/designsafe/apps/api/publications_v2/apps.py +++ b/designsafe/apps/api/publications_v2/apps.py @@ -1,9 +1,9 @@ -"""Projects V2 API""" +"""Publications V2 API""" from django.apps import AppConfig -class ProjectsV2AppConfig(AppConfig): - """App config for Projects V2 API""" +class PublicationsV2AppConfig(AppConfig): + """App config for Publications V2 API""" name = "designsafe.apps.api.publications_v2" label = "publications_v2_api" diff --git a/designsafe/apps/api/urls.py b/designsafe/apps/api/urls.py index c45605fcac..7af0b1dc91 100644 --- a/designsafe/apps/api/urls.py +++ b/designsafe/apps/api/urls.py @@ -16,6 +16,8 @@ url(r'^datafiles/', include('designsafe.apps.api.datafiles.urls')), url(r'^publications/', include('designsafe.apps.api.publications.urls')), + url(r'^filemeta/', include('designsafe.apps.api.filemeta.urls')), + url(r'^logger/$', LoggerApi.as_view(), name='logger'), url(r'^notifications/', include('designsafe.apps.api.notifications.urls')), url(r'^users/', include('designsafe.apps.api.users.urls')), diff --git a/designsafe/apps/api/views.py b/designsafe/apps/api/views.py index dffe31a90e..1ee0eb4d0c 100644 --- a/designsafe/apps/api/views.py +++ b/designsafe/apps/api/views.py @@ -1,4 +1,4 @@ -from django.http.response import HttpResponse, HttpResponseForbidden +from django.http.response import HttpResponse, HttpResponseForbidden, JsonResponse from django.views.generic import View from requests.exceptions import ConnectionError, HTTPError from .exceptions import ApiException @@ -51,6 +51,16 @@ def dispatch(self, request, *args, **kwargs): status=status, content_type='application/json') +class AuthenticatedApiView(BaseApiView): + + def dispatch(self, request, *args, **kwargs): + """Returns 401 if user is not authenticated.""" + + if not request.user.is_authenticated: + return JsonResponse({"message": "Unauthenticated user"}, status=401) + return super(AuthenticatedApiView, self).dispatch(request, *args, **kwargs) + + class LoggerApi(BaseApiView): """ Logger API for capturing logs from the front-end. diff --git a/designsafe/settings/common_settings.py b/designsafe/settings/common_settings.py index baa94d97bd..6c2c96760e 100644 --- a/designsafe/settings/common_settings.py +++ b/designsafe/settings/common_settings.py @@ -87,6 +87,7 @@ 'designsafe.apps.api.datafiles', 'designsafe.apps.api.projects_v2', 'designsafe.apps.api.publications_v2', + 'designsafe.apps.api.filemeta', 'designsafe.apps.accounts', 'designsafe.apps.cms_plugins', 'designsafe.apps.box_integration', diff --git a/designsafe/settings/test_settings.py b/designsafe/settings/test_settings.py index e88bd5e0be..44b03240c7 100644 --- a/designsafe/settings/test_settings.py +++ b/designsafe/settings/test_settings.py @@ -85,6 +85,7 @@ 'designsafe.apps.api', 'designsafe.apps.api.notifications', 'designsafe.apps.api.projects_v2', + 'designsafe.apps.api.filemeta', 'designsafe.apps.accounts', 'designsafe.apps.cms_plugins', 'designsafe.apps.box_integration', From db2581495d40a13765553af98251a660493f9f81 Mon Sep 17 00:00:00 2001 From: Wesley B <62723358+wesleyboar@users.noreply.github.com> Date: Mon, 22 Apr 2024 15:42:28 -0500 Subject: [PATCH 2/3] feat: des-2737 finish app test page markup & styles (#1208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix/des 2730 adjust app page styles (#1206) * fix: des-2730 app-card spacing * fix: des-2730 app-card repo italic * fix: des-2730 version-list spacing * fix: des-2730 improve heading visual hierarchy * chore: des-2730 do not load app-card.css globally Because DesignSafe-CI/portal#1199 allows loading it via template. * chore: des-2730 establish s-version-list markup * fix: des-2730 s-version-list neglects html case I just copied this before, but forgot to update it. * fix: des-2730 s-version-list lost padding When button/a was in a `

`, the padding came from Bootstrap. But now button/a is not in a `

`, so padding must be added. * feat: des-2730 o-app-grid * feat: des-2730 s-app-page overwrite bootstrap grid * feat: des-2730 app-page.css load app-grid.css * Fix/des 2731 integrate plugins and styles (#1205) * fix: des-2731 util class not quick inline styles * fix: des-2731 version list inline styles + html * refactor: des-2731 o-app-grid not u-app-card-grid * fix: des-2735 app page not loading app page css (#1210) * refactor: des-2736 better integrate styles into cms (#1209) * refactor: des-2736 …version-list→…app-version-list * fix: des-2735 app page not loading app page css * refactor: des-2737 move plugin styles → django app - move plugin styles into appropriate django app - load app styles only once per page * fix: des-2737 fix incomplete rename * refactor: des-2737 simplify a too-complex selector * fix: des-2737 support new icons * refactor: des-2737 load app css globally (#1213) --- .../designsafe/apps/workspace/app_card.html | 4 +- .../apps/workspace/app_listing_plugin.html | 2 +- .../apps/workspace/app_variant_plugin.html | 13 ++--- .../apps/workspace/related_apps_plugin.html | 2 +- designsafe/static/styles/app-card.css | 15 +++--- designsafe/static/styles/app-grid.css | 12 +++++ designsafe/static/styles/app-page.css | 22 +++++++-- designsafe/static/styles/app-version-list.css | 47 ++++++++++++------- designsafe/templates/base.j2 | 1 - designsafe/templates/cms_page_for_app.html | 9 ++-- 10 files changed, 81 insertions(+), 46 deletions(-) create mode 100644 designsafe/static/styles/app-grid.css diff --git a/designsafe/apps/workspace/templates/designsafe/apps/workspace/app_card.html b/designsafe/apps/workspace/templates/designsafe/apps/workspace/app_card.html index 1e1e6f3bde..e31df8ea1f 100644 --- a/designsafe/apps/workspace/templates/designsafe/apps/workspace/app_card.html +++ b/designsafe/apps/workspace/templates/designsafe/apps/workspace/app_card.html @@ -1,5 +1,5 @@ - -

{{app.label}}

+ +

{{app.label}}

{{app.description}}

diff --git a/designsafe/apps/workspace/templates/designsafe/apps/workspace/app_listing_plugin.html b/designsafe/apps/workspace/templates/designsafe/apps/workspace/app_listing_plugin.html index 6d0a96afb7..69ffb3ce88 100644 --- a/designsafe/apps/workspace/templates/designsafe/apps/workspace/app_listing_plugin.html +++ b/designsafe/apps/workspace/templates/designsafe/apps/workspace/app_listing_plugin.html @@ -1,5 +1,5 @@

{{instance.app_category}}

-
+
{% for app in listing %} {% include "designsafe/apps/workspace/app_card.html" with app=app %} {% endfor %} diff --git a/designsafe/apps/workspace/templates/designsafe/apps/workspace/app_variant_plugin.html b/designsafe/apps/workspace/templates/designsafe/apps/workspace/app_variant_plugin.html index ac7a2bf29d..f9786ba005 100644 --- a/designsafe/apps/workspace/templates/designsafe/apps/workspace/app_variant_plugin.html +++ b/designsafe/apps/workspace/templates/designsafe/apps/workspace/app_variant_plugin.html @@ -1,15 +1,10 @@ -
-

Select a Version

+
+

Select a Version

{% for variant in listing %}
{% endfor %}
diff --git a/designsafe/apps/workspace/templates/designsafe/apps/workspace/related_apps_plugin.html b/designsafe/apps/workspace/templates/designsafe/apps/workspace/related_apps_plugin.html index 8776b428b8..8e1c778649 100644 --- a/designsafe/apps/workspace/templates/designsafe/apps/workspace/related_apps_plugin.html +++ b/designsafe/apps/workspace/templates/designsafe/apps/workspace/related_apps_plugin.html @@ -1,5 +1,5 @@

Related Applications

-
+
{% for app in listing %} {% include "designsafe/apps/workspace/app_card.html" with app=app %} {% endfor %} diff --git a/designsafe/static/styles/app-card.css b/designsafe/static/styles/app-card.css index b95a9cf0cb..9c2e91cabf 100644 --- a/designsafe/static/styles/app-card.css +++ b/designsafe/static/styles/app-card.css @@ -5,16 +5,9 @@ -/* Card */ -/* To stretch card to fill column height */ -[class^="col-"] .c-app-card { - height: 100%; - min-height: 230px; -} - /* Title */ .c-app-card__title { - margin-top: 0.5em; + margin-top: 1em; margin-bottom: 0; } .c-app-card__title > .icon::before { @@ -23,7 +16,7 @@ /* Description */ .c-app-card__desc { - padding-inline: 1rem; + padding-inline: 1.5rem; margin-block: 1.5rem; } @@ -89,3 +82,7 @@ a.c-app-card:active { .c-app-card__flags > *:has(strong) { background-color: #ECE4BF; } + +.c-app-card__repo { + font-style: italic; +} diff --git a/designsafe/static/styles/app-grid.css b/designsafe/static/styles/app-grid.css new file mode 100644 index 0000000000..fcf4eeb76f --- /dev/null +++ b/designsafe/static/styles/app-grid.css @@ -0,0 +1,12 @@ +.o-app-grid { + display: grid; + gap: 30px; /* mimics Bootstrap's gap */ + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + justify-content: flex-start; + + margin-bottom: 40px; +} +.o-app-grid > * { + min-height: 230px; + max-width: 350px; +} diff --git a/designsafe/static/styles/app-page.css b/designsafe/static/styles/app-page.css index ff83869aed..039c62188d 100644 --- a/designsafe/static/styles/app-page.css +++ b/designsafe/static/styles/app-page.css @@ -1,4 +1,5 @@ @import url("./app-card.css"); +@import url("./app-grid.css"); @import url("./app-version-list.css"); /* To make width of page content line up with width of header. */ @@ -11,22 +12,35 @@ color: var(--ds-accent-color, #47a59d); } .s-app-page h2 { - font-size: 2.0rem; + font-size: 2.5rem; + font-weight: 500; /* e.g. "medium" */ text-transform: none; margin-bottom: 30px; } -.s-app-page h2:not(.s-version-list *) { +.s-app-page h2:not(.s-app-version-list *) { color: var(--ds-accent-color, #47a59d); margin-top: 40px; padding-bottom: 16px; border-bottom: 2px solid var(--ds-accent-color, #47a59d); } -.s-app-page h3:not(.s-version-list *):not(.c-app-card__title) { +.s-app-page h3:not(.s-app-version-list *):not(.c-app-card__title) { + font-size: 1.8rem; margin-top: 40px; /* double Bootstrap h3 margin-top */ } -p:where(.s-app-page *) { +.s-app-page p { line-height: 1.8; } + +/* Add more space between Bootstrap columns */ +/* HACK: Overwrite Bootstrap grid */ +.s-app-page [class*="col-"] { + padding-left: 30px; + padding-right: 30px; +} +.s-app-page .row { + margin-left: -30px; + margin-right: -30px; +} diff --git a/designsafe/static/styles/app-version-list.css b/designsafe/static/styles/app-version-list.css index ff60910bfa..93f4b82220 100644 --- a/designsafe/static/styles/app-version-list.css +++ b/designsafe/static/styles/app-version-list.css @@ -1,27 +1,38 @@ -.s-version-list { +/* FAQ: Relies on markup, so CMS can replicate the design */ + +/* Container */ +.s-app-version-list { background-color: #F4F4F4; padding: 20px; } -.s-version-list > h2 { + +/* List Title */ +.s-app-version-list > h2 { color: inherit; margin-top: unset; padding-bottom: unset; border-bottom: unset; } -.s-version-list > article { - padding-top: 30px; + +/* Version Content Layout */ +.s-app-version-list > article { display: grid; grid-template-areas: "name link" "desc desc"; } -.s-version-list > article:not(:last-of-type) { + +/* Space & Lines Between Versions */ +.s-app-version-list > article:not(:last-of-type) { padding-bottom: 15px; } -.s-version-list > *:not(:first-of-type) { +.s-app-version-list > article:not(:first-of-type) { + padding-top: 30px; border-top: 1px solid #333333; } -.s-version-list > * > h3 { + +/* Version Label */ +.s-app-version-list > article > h3 { grid-area: name; font-size: 1.6rem; font-weight: 500; /* e.g. "medium", Core-Styles `var(--medium)` */ @@ -31,22 +42,26 @@ display: grid; align-content: center; } + +/* Version Link */ +.s-app-version-list > article > a, +/* To support manual content via CMS */ /* FAQ: CMS forces a button or link on its own line to be in a paragraph */ -.s-version-list > * > p:has( - a:only-child, - button:only-child -) { +.s-app-version-list > * > p:has(a:only-child) { grid-area: link; justify-self: end; } -.s-version-list > * > p:not(:has( - a:only-child, - button:only-child -)) { +.s-app-version-list > article > a { + margin-bottom: 10px; /* mimics Bootstrap `p { margin: 0 0 10px; }` */ +} +.s-app-version-list > * > p:only-of-type, +/* To support manual content via CMS */ +/* FAQ: CMS forces a button or link on its own line to be in a paragraph */ +.s-app-version-list > * > p:not(:has(a:only-child)) { grid-area: desc; } /* Bootstrap */ -.s-version-list .btn { +.s-app-version-list .btn { padding-inline: 24px; /* double Bootstrap .btn padding */ } diff --git a/designsafe/templates/base.j2 b/designsafe/templates/base.j2 index 7d64b1f8eb..664eaf05f5 100644 --- a/designsafe/templates/base.j2 +++ b/designsafe/templates/base.j2 @@ -35,7 +35,6 @@ - {% block styles %}{% endblock %} {% render_block "css" %} diff --git a/designsafe/templates/cms_page_for_app.html b/designsafe/templates/cms_page_for_app.html index 73c5b42b00..5dead1dcf0 100644 --- a/designsafe/templates/cms_page_for_app.html +++ b/designsafe/templates/cms_page_for_app.html @@ -1,6 +1,9 @@ {% extends "cms_page.html" %} {% load cms_tags static sekizai_tags %} {% block page_class %}s-app-page{% endblock page_class %} -{% addtoblock "css" %} - -{% endaddtoblock %} + +{% block styles %} + {% addtoblock "css" %} + + {% endaddtoblock %} +{% endblock %} From 2605554f22a30c3aebbf15d7d79b476e327f87d4 Mon Sep 17 00:00:00 2001 From: Jake Rosenberg Date: Mon, 22 Apr 2024 15:57:22 -0500 Subject: [PATCH 3/3] initial working pipeline steps for type Hybrid Sim (#1201) * initial working pipeline steps for type Hybrid Sim * add UI for changing project type * import fix * add selection page for publish vs amend/revise * formatting * linting * Add change_project_type function and test cases (#1214) * fixes for citations/tree view; add listings to entity view * update publication ingest to add all versions * add project selection to copy modal * linting fixes --------- Co-authored-by: Van Go <35277477+van-go@users.noreply.github.com> --- .../projects/UseValidateEntitySelection.ts | 32 ++ .../_hooks/src/datafiles/projects/index.ts | 4 + .../_hooks/src/datafiles/projects/types.ts | 22 +- .../projects/usePatchEntityMetadata.ts | 36 ++ .../projects/usePatchProjectMetadata.ts | 33 ++ .../datafiles/projects/useProjectDetail.ts | 4 +- .../datafiles/projects/useProjectPreview.tsx | 4 +- .../publications/usePublicationDetail.ts | 17 +- .../_hooks/src/datafiles/useFileListing.ts | 3 + .../src/datafiles/usePathDisplayName.ts | 2 +- .../_hooks/src/datafiles/useSelectedFiles.ts | 36 +- .../AddFileFolder/AddFileFolder.module.css | 1 - .../src/AddFileFolder/AddFileFolder.tsx | 31 +- .../DatafilesBreadcrumb.tsx | 27 +- .../CopyModal/CopyModal.module.css | 6 + .../DatafilesModal/CopyModal/CopyModal.tsx | 168 +++++++-- .../CopyModal/CopyModalProjectListing.tsx | 57 +++ .../src/DatafilesSideNav/DatafilesSideNav.tsx | 2 +- .../src/DatafilesToolbar/DatafilesToolbar.tsx | 1 + .../FileListingTable/FileListingTable.tsx | 3 + .../src/projects/BaseProjectDetails.tsx | 6 +- .../ProjectCitation/ProjectCitation.tsx | 51 +++ .../ProjectCollapser/ProjectCollapser.tsx | 10 +- .../ProjectPipeline/PipelineOrderAuthors.tsx | 166 +++++++++ .../PipelineOtherSelectFiles.tsx | 115 ++++++ .../PipelineProofreadCategories.tsx | 66 ++++ .../PipelineProofreadProjectStep.tsx | 57 +++ .../PipelineProofreadPublications.tsx | 66 ++++ .../PipelineSelectForPublish.tsx | 140 ++++++++ .../ProjectPipeline/PipelineSelectLicense.tsx | 288 ++++++++++++++++ .../ProjectPipeline/ProjectPipeline.tsx | 151 ++++++++ .../ProjectPreview/ProjectPreview.module.css | 3 + .../ProjectPreview/ProjectPreview.tsx | 148 ++++++-- .../ProjectTitleHeader/ProjectTitleHeader.tsx | 2 +- .../ProjectTree/ProjectTree.module.css | 3 +- .../src/projects/ProjectTree/ProjectTree.tsx | 21 +- .../datafiles/src/projects/constants.ts | 2 +- .../src/projects/forms/BaseProjectForm.tsx | 203 ++++++++--- .../projects/forms/ProjectFormDropdowns.ts | 12 + .../projects/forms/PublishableEntityForm.tsx | 34 +- .../projects/forms/_fields/AuthorSelect.tsx | 32 ++ .../modules/datafiles/src/projects/index.ts | 1 + .../modals/BaseProjectUpdateModal.tsx | 8 +- .../modals/ChangeProjectTypeModal.tsx | 296 ++++++++++++++++ .../ProjectInfoStepper/ExperimentalSteps.tsx | 264 ++++++++++++++ .../ProjectInfoStepper/FieldReconSteps.tsx | 326 ++++++++++++++++++ .../ProjectInfoStepper/SimulationSteps.tsx | 262 ++++++++++++++ .../sensitiveDataContext.ts | 6 + .../modals/ProjectTypeRadioSelect.tsx | 23 ++ .../datafiles/src/projects/modals/index.ts | 1 + client/src/datafiles/datafilesRouter.tsx | 19 +- .../projects/ProjectPipelineLayout.tsx | 10 +- .../projects/ProjectPipelineSelectLayout.tsx | 110 ++++++ .../layouts/projects/ProjectPreviewLayout.tsx | 18 +- .../published/PublishedDetailLayout.tsx | 26 +- .../PublishedEntityListingLayout.tsx | 17 + .../published/PublishedFileListingLayout.tsx | 41 +++ client/src/main.tsx | 3 + client/src/styles.css | 4 + .../migration_utils/file_obj_ingest.py | 113 ++++++ .../migration_utils/graph_constructor.py | 26 +- .../migration_utils/project_db_ingest.py | 26 +- .../migration_utils/publication_transforms.py | 60 +++- .../_tests/project_meta_unit_test.py | 23 ++ .../operations/project_meta_operations.py | 14 + .../operations/project_publish_operations.py | 106 +++++- designsafe/apps/api/projects_v2/urls.py | 5 +- designsafe/apps/api/projects_v2/views.py | 99 +++++- designsafe/apps/api/publications_v2/urls.py | 7 +- designsafe/apps/api/publications_v2/views.py | 2 +- designsafe/apps/api/urls.py | 2 +- 71 files changed, 3744 insertions(+), 239 deletions(-) create mode 100644 client/modules/_hooks/src/datafiles/projects/UseValidateEntitySelection.ts create mode 100644 client/modules/_hooks/src/datafiles/projects/usePatchEntityMetadata.ts create mode 100644 client/modules/_hooks/src/datafiles/projects/usePatchProjectMetadata.ts create mode 100644 client/modules/datafiles/src/DatafilesModal/CopyModal/CopyModalProjectListing.tsx create mode 100644 client/modules/datafiles/src/projects/ProjectCitation/ProjectCitation.tsx create mode 100644 client/modules/datafiles/src/projects/ProjectPipeline/PipelineOrderAuthors.tsx create mode 100644 client/modules/datafiles/src/projects/ProjectPipeline/PipelineOtherSelectFiles.tsx create mode 100644 client/modules/datafiles/src/projects/ProjectPipeline/PipelineProofreadCategories.tsx create mode 100644 client/modules/datafiles/src/projects/ProjectPipeline/PipelineProofreadProjectStep.tsx create mode 100644 client/modules/datafiles/src/projects/ProjectPipeline/PipelineProofreadPublications.tsx create mode 100644 client/modules/datafiles/src/projects/ProjectPipeline/PipelineSelectForPublish.tsx create mode 100644 client/modules/datafiles/src/projects/ProjectPipeline/PipelineSelectLicense.tsx create mode 100644 client/modules/datafiles/src/projects/ProjectPipeline/ProjectPipeline.tsx create mode 100644 client/modules/datafiles/src/projects/forms/_fields/AuthorSelect.tsx create mode 100644 client/modules/datafiles/src/projects/modals/ChangeProjectTypeModal.tsx create mode 100644 client/modules/datafiles/src/projects/modals/ProjectInfoStepper/ExperimentalSteps.tsx create mode 100644 client/modules/datafiles/src/projects/modals/ProjectInfoStepper/FieldReconSteps.tsx create mode 100644 client/modules/datafiles/src/projects/modals/ProjectInfoStepper/SimulationSteps.tsx create mode 100644 client/modules/datafiles/src/projects/modals/ProjectInfoStepper/sensitiveDataContext.ts create mode 100644 client/modules/datafiles/src/projects/modals/ProjectTypeRadioSelect.tsx create mode 100644 client/src/datafiles/layouts/projects/ProjectPipelineSelectLayout.tsx create mode 100644 client/src/datafiles/layouts/published/PublishedEntityListingLayout.tsx create mode 100644 client/src/datafiles/layouts/published/PublishedFileListingLayout.tsx create mode 100644 designsafe/apps/api/projects_v2/migration_utils/file_obj_ingest.py diff --git a/client/modules/_hooks/src/datafiles/projects/UseValidateEntitySelection.ts b/client/modules/_hooks/src/datafiles/projects/UseValidateEntitySelection.ts new file mode 100644 index 0000000000..fad8672179 --- /dev/null +++ b/client/modules/_hooks/src/datafiles/projects/UseValidateEntitySelection.ts @@ -0,0 +1,32 @@ +import { useMutation } from '@tanstack/react-query'; +import apiClient from '../../apiClient'; + +export type TPipelineValidationResult = { + errorType: string; + name: string; + title: string; + missing: string[]; +}; + +async function validateEntitySelection( + projectId: string, + entityUuids: string[] +) { + const res = await apiClient.post<{ result: TPipelineValidationResult[] }>( + `/api/projects/v2/${projectId}/entities/validate/`, + { entityUuids } + ); + return res.data; +} + +export function useValidateEntitySelection() { + return useMutation({ + mutationFn: ({ + projectId, + entityUuids, + }: { + projectId: string; + entityUuids: string[]; + }) => validateEntitySelection(projectId, entityUuids), + }); +} diff --git a/client/modules/_hooks/src/datafiles/projects/index.ts b/client/modules/_hooks/src/datafiles/projects/index.ts index fcbb4f5691..32cdb6fa96 100644 --- a/client/modules/_hooks/src/datafiles/projects/index.ts +++ b/client/modules/_hooks/src/datafiles/projects/index.ts @@ -12,3 +12,7 @@ export { useRemoveEntityFromTree } from './useRemoveEntityFromTree'; export { useAddFileAssociation } from './useAddFileAssociation'; export { useRemoveFileAssociation } from './useRemoveFileAssociation'; export { useSetFileTags } from './useSetFileTags'; +export { usePatchEntityMetadata } from './usePatchEntityMetadata'; +export { usePatchProjectMetadata } from './usePatchProjectMetadata'; +export { useValidateEntitySelection } from './UseValidateEntitySelection'; +export type { TPipelineValidationResult } from './UseValidateEntitySelection'; diff --git a/client/modules/_hooks/src/datafiles/projects/types.ts b/client/modules/_hooks/src/datafiles/projects/types.ts index 0d1b65b6d8..7b8f0bc483 100644 --- a/client/modules/_hooks/src/datafiles/projects/types.ts +++ b/client/modules/_hooks/src/datafiles/projects/types.ts @@ -91,11 +91,14 @@ export type TBaseProjectValue = { dois: string[]; fileObjs: TFileObj[]; fileTags: TFileTag[]; + + license?: string; }; -type TEntityValue = { +export type TEntityValue = { title: string; description?: string; + projectId?: string; authors?: TProjectUser[]; fileObjs?: TFileObj[]; fileTags: TFileTag[]; @@ -116,3 +119,20 @@ export type TBaseProject = TProjectMeta & { export type TEntityMeta = TProjectMeta & { value: TEntityValue; }; + +export type TPreviewTreeData = { + name: string; + id: string; + uuid: string; + value: TEntityValue; + order: number; + children: TPreviewTreeData[]; +}; + +export type TTreeData = { + name: string; + id: string; + uuid: string; + order: number; + children: TTreeData[]; +}; diff --git a/client/modules/_hooks/src/datafiles/projects/usePatchEntityMetadata.ts b/client/modules/_hooks/src/datafiles/projects/usePatchEntityMetadata.ts new file mode 100644 index 0000000000..4a189a2012 --- /dev/null +++ b/client/modules/_hooks/src/datafiles/projects/usePatchEntityMetadata.ts @@ -0,0 +1,36 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import apiClient from '../../apiClient'; + +async function patchEntityMetadata( + entityUuid: string, + patchMetadata: Record +) { + // Replace undefined with null so that deleted values are unset instead of ignored. + Object.keys(patchMetadata).forEach((k) => { + if (patchMetadata[k] === undefined) { + patchMetadata[k] = null; + } + }); + const res = await apiClient.patch( + `/api/projects/v2/entities/${entityUuid}/`, + { patchMetadata } + ); + return res.data; +} + +export function usePatchEntityMetadata() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + entityUuid, + patchMetadata, + }: { + patchMetadata: Record; + entityUuid: string; + }) => patchEntityMetadata(entityUuid, patchMetadata), + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: ['datafiles', 'projects', 'detail'], + }), + }); +} diff --git a/client/modules/_hooks/src/datafiles/projects/usePatchProjectMetadata.ts b/client/modules/_hooks/src/datafiles/projects/usePatchProjectMetadata.ts new file mode 100644 index 0000000000..8a6ad4722d --- /dev/null +++ b/client/modules/_hooks/src/datafiles/projects/usePatchProjectMetadata.ts @@ -0,0 +1,33 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import apiClient from '../../apiClient'; + +async function patchProjectMetadata( + projectId: string, + patchMetadata: Record +) { + // Replace undefined with null so that deleted values are unset instead of ignored. + Object.keys(patchMetadata).forEach((k) => { + if (patchMetadata[k] === undefined) { + patchMetadata[k] = null; + } + }); + const res = await apiClient.patch(`/api/projects/v2/${projectId}/`, { + patchMetadata, + }); + return res.data; +} + +export function usePatchProjectMetadata(projectId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + patchMetadata, + }: { + patchMetadata: Record; + }) => patchProjectMetadata(projectId, patchMetadata), + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: ['datafiles', 'projects', 'detail', projectId], + }), + }); +} diff --git a/client/modules/_hooks/src/datafiles/projects/useProjectDetail.ts b/client/modules/_hooks/src/datafiles/projects/useProjectDetail.ts index 3035c52762..0995284d2d 100644 --- a/client/modules/_hooks/src/datafiles/projects/useProjectDetail.ts +++ b/client/modules/_hooks/src/datafiles/projects/useProjectDetail.ts @@ -1,12 +1,12 @@ import { useQuery } from '@tanstack/react-query'; import apiClient from '../../apiClient'; -import { TBaseProject, TEntityMeta, TFileTag } from './types'; +import { TBaseProject, TEntityMeta, TFileTag, TTreeData } from './types'; import { useMemo } from 'react'; type TProjectDetailResponse = { baseProject: TBaseProject; entities: TEntityMeta[]; - tree: unknown; + tree: TTreeData; }; async function getProjectDetail({ diff --git a/client/modules/_hooks/src/datafiles/projects/useProjectPreview.tsx b/client/modules/_hooks/src/datafiles/projects/useProjectPreview.tsx index e6da9b8255..fe5598199a 100644 --- a/client/modules/_hooks/src/datafiles/projects/useProjectPreview.tsx +++ b/client/modules/_hooks/src/datafiles/projects/useProjectPreview.tsx @@ -1,11 +1,11 @@ import { useQuery } from '@tanstack/react-query'; import apiClient from '../../apiClient'; -import { TBaseProject, TEntityMeta } from './types'; +import { TBaseProject, TEntityMeta, TPreviewTreeData } from './types'; type TProjectPreviewResponse = { baseProject: TBaseProject; entities: TEntityMeta[]; - tree: unknown; + tree: TPreviewTreeData; }; async function getProjectPreview({ diff --git a/client/modules/_hooks/src/datafiles/publications/usePublicationDetail.ts b/client/modules/_hooks/src/datafiles/publications/usePublicationDetail.ts index 4209944299..0bda571e8d 100644 --- a/client/modules/_hooks/src/datafiles/publications/usePublicationDetail.ts +++ b/client/modules/_hooks/src/datafiles/publications/usePublicationDetail.ts @@ -1,9 +1,22 @@ import { useQuery } from '@tanstack/react-query'; import apiClient from '../../apiClient'; -import { TBaseProjectValue } from '../projects'; +import { TBaseProjectValue, TEntityValue } from '../projects'; + +export type TPublicationTree = { + name: string; + uuid: string; + id: string; + basePath: string; + value: T; + publicationDate: string; + status: string; + order: number; + version?: number; + children: TPublicationTree[]; +}; export type TPublicationDetailResponse = { - tree: unknown; + tree: TPublicationTree; baseProject: TBaseProjectValue; }; diff --git a/client/modules/_hooks/src/datafiles/useFileListing.ts b/client/modules/_hooks/src/datafiles/useFileListing.ts index 7d8b43ef50..a556e38668 100644 --- a/client/modules/_hooks/src/datafiles/useFileListing.ts +++ b/client/modules/_hooks/src/datafiles/useFileListing.ts @@ -45,6 +45,7 @@ type TFileListingHookArgs = { path: string; scheme: string; pageSize: number; + disabled?: boolean; }; type TFileListingPageParam = { @@ -58,6 +59,7 @@ function useFileListing({ path, scheme = 'private', pageSize = 100, + disabled = false, }: TFileListingHookArgs) { return useInfiniteQuery< FileListingResponse, @@ -78,6 +80,7 @@ function useFileListing({ signal, } ), + enabled: !disabled, getNextPageParam: (lastPage, allpages): TFileListingPageParam | null => { return lastPage.listing.length >= pageSize ? { page: allpages.length, nextPageToken: lastPage.nextPageToken } diff --git a/client/modules/_hooks/src/datafiles/usePathDisplayName.ts b/client/modules/_hooks/src/datafiles/usePathDisplayName.ts index c84b8c6cc2..da5d378a61 100644 --- a/client/modules/_hooks/src/datafiles/usePathDisplayName.ts +++ b/client/modules/_hooks/src/datafiles/usePathDisplayName.ts @@ -31,7 +31,7 @@ function _getPathDisplayName( return 'My Data'; } if (system === 'designsafe.storage.frontera.work' && path === usernamePath) { - return 'My Data (Work)'; + return 'HPC Work'; } return decodeURIComponent(path).split('/').slice(-1)[0] || 'Data Files'; diff --git a/client/modules/_hooks/src/datafiles/useSelectedFiles.ts b/client/modules/_hooks/src/datafiles/useSelectedFiles.ts index 27b5f371d9..2edab34bca 100644 --- a/client/modules/_hooks/src/datafiles/useSelectedFiles.ts +++ b/client/modules/_hooks/src/datafiles/useSelectedFiles.ts @@ -19,24 +19,48 @@ export function useSelectedFiles( const queryClient = useQueryClient(); const setSelectedFiles = useCallback( - (selection: TFileListing[]) => - queryClient.setQueryData(queryKey, selection), + (selection: TFileListing[]) => { + queryClient.setQueryData(queryKey, selection); + queryClient.invalidateQueries({ queryKey: ['rows-for-system'] }); + }, [queryKey, queryClient] ); - return { selectedFiles: selectedRowsQuery.data, setSelectedFiles }; + const unsetSelections = useCallback(() => { + queryClient.setQueriesData({ queryKey: ['selected-rows'] }, () => []); + queryClient.invalidateQueries({ queryKey: ['rows-for-system'] }); + }, [queryClient]); + + return { + selectedFiles: selectedRowsQuery.data, + setSelectedFiles, + unsetSelections, + }; } export function useSelectedFilesForSystem(api: string, system: string) { // Get all selected files matching a given system. // Used when multiple listings can be present in a single page, e.g. publications. - const queryKey = ['selected-rows', api, system]; + const queryClient = useQueryClient(); - const selections = queryClient.getQueriesData({ queryKey }); + /* + const selections = useMemo(() => { + const queryKey = ['selected-rows', api, system]; + return queryClient.getQueriesData({ queryKey }); + }, [api, system, queryClient]); + */ + + const { data: selections } = useQuery({ + queryKey: ['rows-for-system', api, system], + queryFn: () => { + const queryKey = ['selected-rows', api, system]; + return queryClient.getQueriesData({ queryKey }); + }, + }); const reducedSelections = useMemo(() => { const allSelections: TFileListing[] = []; - selections.forEach((s) => s[1] && allSelections.push(...s[1])); + (selections ?? []).forEach((s) => s[1] && allSelections.push(...s[1])); return allSelections; }, [selections]); return reducedSelections; diff --git a/client/modules/datafiles/src/AddFileFolder/AddFileFolder.module.css b/client/modules/datafiles/src/AddFileFolder/AddFileFolder.module.css index dbf8538188..0f4239d8ad 100644 --- a/client/modules/datafiles/src/AddFileFolder/AddFileFolder.module.css +++ b/client/modules/datafiles/src/AddFileFolder/AddFileFolder.module.css @@ -35,7 +35,6 @@ a.navLink:not(:global(.active)):hover > div { } .customUl { - border: 1px solid #e3e3e3; list-style-type: none; padding-left: 0; } diff --git a/client/modules/datafiles/src/AddFileFolder/AddFileFolder.tsx b/client/modules/datafiles/src/AddFileFolder/AddFileFolder.tsx index 76b4756193..e624e8cfbe 100644 --- a/client/modules/datafiles/src/AddFileFolder/AddFileFolder.tsx +++ b/client/modules/datafiles/src/AddFileFolder/AddFileFolder.tsx @@ -102,22 +102,23 @@ export const AddFileFolder: React.FC = () => { )} -
  • { - e.preventDefault(); - window.location.href = - 'https://www.designsafe-ci.org/rw/user-guides/data-transfer-guide/'; - }} - > - + +
  • diff --git a/client/modules/datafiles/src/DatafilesBreadcrumb/DatafilesBreadcrumb.tsx b/client/modules/datafiles/src/DatafilesBreadcrumb/DatafilesBreadcrumb.tsx index cc01ec4d8e..dda77dfcb4 100644 --- a/client/modules/datafiles/src/DatafilesBreadcrumb/DatafilesBreadcrumb.tsx +++ b/client/modules/datafiles/src/DatafilesBreadcrumb/DatafilesBreadcrumb.tsx @@ -36,6 +36,7 @@ export const DatafilesBreadcrumb: React.FC< baseRoute: string; systemRoot: string; systemRootAlias?: string; + skipBreadcrumbs?: number; // Number of path elements to skip when generating breadcrumbs } & BreadcrumbProps > = ({ initialBreadcrumbs, @@ -43,11 +44,14 @@ export const DatafilesBreadcrumb: React.FC< baseRoute, systemRoot, systemRootAlias, + skipBreadcrumbs, ...props }) => { const breadcrumbItems = [ ...initialBreadcrumbs, - ...getPathRoutes(baseRoute, path, systemRoot, systemRootAlias), + ...getPathRoutes(baseRoute, path, systemRoot, systemRootAlias).slice( + skipBreadcrumbs ?? 0 + ), ]; return ( @@ -67,19 +71,32 @@ function isUserHomeSystem(system: string) { } export const BaseFileListingBreadcrumb: React.FC< - { api: string; system: string; path: string } & BreadcrumbProps -> = ({ api, system, path, ...props }) => { + { + api: string; + system: string; + path: string; + systemRootAlias?: string; + initialBreadcrumbs?: { title: string; path: string }[]; + } & BreadcrumbProps +> = ({ + api, + system, + path, + systemRootAlias, + initialBreadcrumbs = [], + ...props +}) => { const { user } = useAuthenticatedUser(); return ( ); diff --git a/client/modules/datafiles/src/DatafilesModal/CopyModal/CopyModal.module.css b/client/modules/datafiles/src/DatafilesModal/CopyModal/CopyModal.module.css index 0d3f1691ea..5ceac66cca 100644 --- a/client/modules/datafiles/src/DatafilesModal/CopyModal/CopyModal.module.css +++ b/client/modules/datafiles/src/DatafilesModal/CopyModal/CopyModal.module.css @@ -19,6 +19,12 @@ height: 100%; } +.modalRightPanel { + display: flex; + flex: 1; + flex-direction: column; +} + .destFilesSection { display: flex; flex: 1; diff --git a/client/modules/datafiles/src/DatafilesModal/CopyModal/CopyModal.tsx b/client/modules/datafiles/src/DatafilesModal/CopyModal/CopyModal.tsx index ffa8e16615..95fae57d4d 100644 --- a/client/modules/datafiles/src/DatafilesModal/CopyModal/CopyModal.tsx +++ b/client/modules/datafiles/src/DatafilesModal/CopyModal/CopyModal.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { TModalChildren } from '../DatafilesModal'; -import { Button, Modal, Table } from 'antd'; +import { Button, Modal, Select, Table } from 'antd'; import { useAuthenticatedUser, useFileCopy, @@ -14,6 +14,7 @@ import { import { BaseFileListingBreadcrumb } from '../../DatafilesBreadcrumb/DatafilesBreadcrumb'; import styles from './CopyModal.module.css'; import { toBytes } from '../../FileListing/FileListing'; +import { CopyModalProjectListing } from './CopyModalProjectListing'; const SelectedFilesColumns: TFileListingColumns = [ { @@ -31,14 +32,15 @@ const DestHeaderTitle: React.FC<{ api: string; system: string; path: string; -}> = ({ api, system, path }) => { + projectId?: string; +}> = ({ api, system, path, projectId }) => { const getPathName = usePathDisplayName(); return (    - {getPathName(api, system, path)} + {projectId || getPathName(api, system, path)} ); }; @@ -48,11 +50,19 @@ function getDestFilesColumns( system: string, path: string, mutationCallback: (path: string) => void, - navCallback: (path: string) => void + navCallback: (path: string) => void, + projectId?: string ): TFileListingColumns { return [ { - title: , + title: ( + + ), dataIndex: 'name', ellipsis: true, @@ -115,12 +125,58 @@ export const CopyModal: React.FC<{ [user] ); - const [dest, setDest] = useState(defaultDestParams); + const [dest, setDest] = useState<{ + destApi: string; + destSystem: string; + destPath: string; + destProjectId?: string; + }>(defaultDestParams); + const [showProjects, setShowProjects] = useState(false); const { destApi, destSystem, destPath } = dest; useEffect(() => setDest(defaultDestParams), [isModalOpen, defaultDestParams]); + const [dropdownValue, setDropdownValue] = useState('mydata'); + const dropdownCallback = (newValue: string) => { + setDropdownValue(newValue); + switch (newValue) { + case 'mydata': + setShowProjects(false); + setDest(defaultDestParams); + break; + case 'hpcwork': + setShowProjects(false); + setDest({ + destApi: 'tapis', + destSystem: 'designsafe.storage.frontera.work', + destPath: encodeURIComponent('/' + user?.username), + }); + break; + case 'myprojects': + setShowProjects(true); + break; + default: + setShowProjects(false); + setDest(defaultDestParams); + break; + } + }; + + const onProjectSelect = (uuid: string, projectId: string) => { + setShowProjects(false); + setDest({ + destApi: 'tapis', + destSystem: `project-${uuid}`, + destPath: '', + destProjectId: projectId, + }); + }; + const navCallback = useCallback( (path: string) => { + if (path === 'PROJECT_LISTING') { + setShowProjects(true); + return; + } const newPath = path.split('/').slice(-1)[0]; setDest({ ...dest, destPath: newPath }); }, @@ -148,9 +204,17 @@ export const CopyModal: React.FC<{ destSystem, destPath, (dPath: string) => mutateCallback(dPath), - navCallback + navCallback, + dest.destProjectId ), - [navCallback, destApi, destSystem, destPath, mutateCallback] + [ + navCallback, + destApi, + destSystem, + destPath, + dest.destProjectId, + mutateCallback, + ] ); return ( @@ -174,34 +238,68 @@ export const CopyModal: React.FC<{ scroll={{ y: '100%' }} />
    -
    - { - return ( - - ); - }} +
    +