From 65b4457161b06d84273c8cbb773c19810a8a2c1b Mon Sep 17 00:00:00 2001 From: Lucas Parzianello Date: Mon, 1 Jun 2026 07:50:10 -0400 Subject: [PATCH 1/5] deps update and formatting --- .../gateway-static-js-refactor/SKILL.md | 2 +- .cursor/skills/jest-test-writing/SKILL.md | 4 +- .gitignore | 4 + .markdownlintignore | 2 + .pre-commit-config.yaml | 2 +- biome.json | 20 ++++ gateway/.vscode/settings.json | 6 +- gateway/biome.jsonc | 33 ++++-- gateway/package.json | 112 +++++++++--------- .../serializers/capture_serializers.py | 26 ++-- .../serializers/dataset_serializers.py | 18 +-- .../capture_details_modal_body.html | 10 +- .../capture_files_summary_fragment.html | 1 + .../dataset_details_modal_body.html | 111 +++++++++-------- .../templates/users/partials/share_modal.html | 4 +- .../users/partials/upload_capture_modal.html | 48 ++++---- .../users/partials/upload_result_modal.html | 38 +++--- gateway/sds_gateway/users/forms.py | 5 +- gateway/sds_gateway/users/tests/test_views.py | 8 +- gateway/sds_gateway/users/urls.py | 2 +- gateway/sds_gateway/users/views/__init__.py | 8 +- gateway/sds_gateway/users/views/captures.py | 4 +- gateway/sds_gateway/users/views/datasets.py | 13 +- .../sds_gateway/users/views/details_modal.py | 21 +++- gateway/sds_gateway/users/views/downloads.py | 2 +- gateway/sds_gateway/users/views/files.py | 5 +- gateway/uv.lock | 50 ++++---- sdk/docs/.markdownlintignore | 2 + 28 files changed, 305 insertions(+), 256 deletions(-) create mode 100644 .markdownlintignore create mode 100644 biome.json create mode 100644 sdk/docs/.markdownlintignore diff --git a/.cursor/skills/gateway-static-js-refactor/SKILL.md b/.cursor/skills/gateway-static-js-refactor/SKILL.md index 842de697d..eb4668d67 100644 --- a/.cursor/skills/gateway-static-js-refactor/SKILL.md +++ b/.cursor/skills/gateway-static-js-refactor/SKILL.md @@ -38,7 +38,7 @@ Follow `.cursor/rules/django_javascript_implementation.mdc` in full. Non-negotia Copy and track: -``` +```text Refactor progress: - [ ] Baseline (fallow + identify targets) - [ ] Propose 2–4 scenarios (user picks or hybrid) diff --git a/.cursor/skills/jest-test-writing/SKILL.md b/.cursor/skills/jest-test-writing/SKILL.md index e8a3ac68e..8361a8ef1 100644 --- a/.cursor/skills/jest-test-writing/SKILL.md +++ b/.cursor/skills/jest-test-writing/SKILL.md @@ -33,13 +33,13 @@ Config: `sds_gateway/static/js/tests-config/jest.config.js` (jsdom, `clearMocks` ## What to test (and what not to) -**Do** +## Do - Public methods and user-visible outcomes (DOM updates, calls to `DOMUtils`, `APIClient`, Bootstrap modal show/hide) - Branches that encode product rules (permissions denied, missing modal, API error responses) - Async flows: `await` the method under test, then assert mocks/callbacks -**Avoid** +## Avoid - Asserting private helpers or internal call order unless order is the contract - Tests that only `expect(x).toBeDefined()` or mirror the implementation line-for-line diff --git a/.gitignore b/.gitignore index e43b0f988..2097eaaa5 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ .DS_Store + +.agents +seaweedfs +**/agents.md diff --git a/.markdownlintignore b/.markdownlintignore new file mode 100644 index 000000000..e33b51e81 --- /dev/null +++ b/.markdownlintignore @@ -0,0 +1,2 @@ +sdk/docs/mkdocs/getting-started.md +sdk/docs/README.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ce83cb56d..515929454 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -47,7 +47,7 @@ repos: rev: v2.4.15 hooks: - id: biome-check - additional_dependencies: [ "@biomejs/biome@^1.0.0" ] + additional_dependencies: [ "@biomejs/biome@^1.9.4" ] # automatically upgrades Django code to migrates patterns and avoid deprecation warnings - repo: https://github.com/adamchainz/django-upgrade diff --git a/biome.json b/biome.json new file mode 100644 index 000000000..26c405f57 --- /dev/null +++ b/biome.json @@ -0,0 +1,20 @@ +{ + "formatter": { + "indentStyle": "space", + "indentWidth": 4 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "noStaticOnlyClass": "off" + } + } + }, + "javascript": { + "formatter": { + "semicolons": "asNeeded" + } + } +} diff --git a/gateway/.vscode/settings.json b/gateway/.vscode/settings.json index fe25c7c32..0c2f00d29 100644 --- a/gateway/.vscode/settings.json +++ b/gateway/.vscode/settings.json @@ -1,5 +1,5 @@ { - "markdownlint.configFile": "../.markdownlint.yaml", - "python.testing.pytestEnabled": false, - "python.testing.unittestEnabled": false + "markdownlint.configFile": "../.markdownlint.yaml", + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": false } diff --git a/gateway/biome.jsonc b/gateway/biome.jsonc index 2b2ab1bca..3e0a32482 100644 --- a/gateway/biome.jsonc +++ b/gateway/biome.jsonc @@ -1,15 +1,24 @@ // https://biomejs.dev/reference/configuration/#_top { - "formatter": { - // "useEditorconfig": true, // Use the .editorconfig file in the project - // enable this when the vscode ext is updated, remove the settings below. - // https://github.com/biomejs/biome-vscode/releases - "indentStyle": "space", - "indentWidth": 4 - }, - "javascript": { - "formatter": { - "semicolons": "asNeeded" - } - } + "formatter": { + // "useEditorconfig": true, // Use the .editorconfig file in the project + // enable this when the vscode ext is updated, remove the settings below. + // https://github.com/biomejs/biome-vscode/releases + "indentStyle": "space", + "indentWidth": 4 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "noStaticOnlyClass": "off" + } + } + }, + "javascript": { + "formatter": { + "semicolons": "asNeeded" + } + } } diff --git a/gateway/package.json b/gateway/package.json index 180a4c490..fca900a3c 100644 --- a/gateway/package.json +++ b/gateway/package.json @@ -1,58 +1,58 @@ { - "name": "sds_gateway", - "version": "0.0.1", - "devDependencies": { - "fallow": "^2.74.0", - "@babel/core": "^7.25.2", - "@babel/plugin-transform-runtime": "^7.25.4", - "@babel/preset-env": "^7.25.4", - "@biomejs/biome": "2.0.0-beta.5", - "@popperjs/core": "^2.11.8", - "autoprefixer": "^10.4.20", - "babel-jest": "^29.7.0", - "babel-loader": "^9.2.1", - "bootstrap": "^5.3.3", - "css-loader": "^6.5.1", - "jest": "^29.7.0", - "jest-environment-jsdom": "^29.7.0", - "mini-css-extract-plugin": "^2.9.1", - "node-sass-tilde-importer": "^1.0.2", - "pixrem": "^5.0.0", - "postcss": "^8.5.15", - "postcss-loader": "^8.1.1", - "postcss-preset-env": "^9.0.0", - "sass": "^1.79.2", - "sass-loader": "^14.0.0", - "webpack": "^5.94.0", - "webpack-bundle-tracker": "^3.1.1", - "webpack-cli": "^5.1.4", - "webpack-dev-server": "^5.2.4", - "webpack-merge": "^5.8.0" - }, - "dependencies": { - "@babel/runtime": "^7.28.4" - }, - "engines": { - "node": "20" - }, - "browserslist": ["last 2 versions"], - "babel": { - "presets": ["@babel/preset-env"], - "plugins": ["@babel/plugin-transform-runtime"] - }, - "parser": "babel-eslint", - "parserOptions": { - "sourceType": "module", - "allowImportExportEverywhere": true - }, - "scripts": { - "dev": "webpack --config webpack/dev.config.js; webpack serve --config webpack/dev.config.js", - "build": "webpack --config webpack/prod.config.js", - "test": "jest --config sds_gateway/static/js/tests-config/jest.config.js", - "test:watch": "jest --config sds_gateway/static/js/tests-config/jest.config.js --watch", - "test:coverage": "jest --config sds_gateway/static/js/tests-config/jest.config.js --coverage", - "test:ci": "jest --config sds_gateway/static/js/tests-config/jest.config.js --ci --coverage --watchAll=false", - "fallow": "fallow --summary", - "fallow:static-js": "node scripts/fallow-static-js-dead-code.cjs" - } + "name": "sds_gateway", + "version": "0.0.1", + "devDependencies": { + "fallow": "^2.74.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-runtime": "^7.25.4", + "@babel/preset-env": "^7.25.4", + "@biomejs/biome": "^1.9.4", + "@popperjs/core": "^2.11.8", + "autoprefixer": "^10.4.20", + "babel-jest": "^29.7.0", + "babel-loader": "^9.2.1", + "bootstrap": "^5.3.3", + "css-loader": "^6.5.1", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "mini-css-extract-plugin": "^2.9.1", + "node-sass-tilde-importer": "^1.0.2", + "pixrem": "^5.0.0", + "postcss": "^8.5.15", + "postcss-loader": "^8.1.1", + "postcss-preset-env": "^9.0.0", + "sass": "^1.79.2", + "sass-loader": "^14.0.0", + "webpack": "^5.94.0", + "webpack-bundle-tracker": "^3.1.1", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^5.2.4", + "webpack-merge": "^5.8.0" + }, + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "engines": { + "node": "20" + }, + "browserslist": ["last 2 versions"], + "babel": { + "presets": ["@babel/preset-env"], + "plugins": ["@babel/plugin-transform-runtime"] + }, + "parser": "babel-eslint", + "parserOptions": { + "sourceType": "module", + "allowImportExportEverywhere": true + }, + "scripts": { + "dev": "webpack --config webpack/dev.config.js; webpack serve --config webpack/dev.config.js", + "build": "webpack --config webpack/prod.config.js", + "test": "jest --config sds_gateway/static/js/tests-config/jest.config.js", + "test:watch": "jest --config sds_gateway/static/js/tests-config/jest.config.js --watch", + "test:coverage": "jest --config sds_gateway/static/js/tests-config/jest.config.js --coverage", + "test:ci": "jest --config sds_gateway/static/js/tests-config/jest.config.js --ci --coverage --watchAll=false", + "fallow": "fallow --summary", + "fallow:static-js": "node scripts/fallow-static-js-dead-code.cjs" + } } diff --git a/gateway/sds_gateway/api_methods/serializers/capture_serializers.py b/gateway/sds_gateway/api_methods/serializers/capture_serializers.py index d3f3dd8ff..4235bff06 100644 --- a/gateway/sds_gateway/api_methods/serializers/capture_serializers.py +++ b/gateway/sds_gateway/api_methods/serializers/capture_serializers.py @@ -1,12 +1,10 @@ """Capture serializers for the SDS Gateway API methods.""" -import logging from datetime import UTC from datetime import datetime from typing import Any from typing import cast -from django.db.models import Sum from django.utils import timezone as django_timezone from drf_spectacular.utils import extend_schema_field from rest_framework import serializers @@ -15,10 +13,10 @@ from sds_gateway.api_methods.helpers.index_handling import retrieve_indexed_metadata from sds_gateway.api_methods.models import Capture from sds_gateway.api_methods.models import CaptureType -from sds_gateway.api_methods.models import PermissionLevel from sds_gateway.api_methods.models import DEPRECATEDPostProcessedData from sds_gateway.api_methods.models import File from sds_gateway.api_methods.models import ItemType +from sds_gateway.api_methods.models import PermissionLevel from sds_gateway.api_methods.models import UserSharePermission from sds_gateway.api_methods.serializers.summary_serializers import ( DatasetSummarySerializer, @@ -155,13 +153,17 @@ def get_is_shared_with_me(self, capture: Capture) -> bool: """Get whether the capture is shared with the current user.""" request = (self.context or {}).get("request") if request and hasattr(request, "user"): - return UserSharePermission.objects.filter( - shared_with=request.user, - item_type=ItemType.CAPTURE, - item_uuid=capture.uuid, - is_enabled=True, - is_deleted=False, - ).exclude(owner=request.user).exists() + return ( + UserSharePermission.objects.filter( + shared_with=request.user, + item_type=ItemType.CAPTURE, + item_uuid=capture.uuid, + is_enabled=True, + is_deleted=False, + ) + .exclude(owner=request.user) + .exists() + ) return False def get_is_shared(self, capture: Capture) -> bool: @@ -636,7 +638,7 @@ def get_is_shared(self, obj: dict[str, Any]) -> bool: def get_files(self, obj: dict[str, Any]) -> ReturnList[File]: """Get all files from all channels in the composite capture.""" all_files: list[File] = [] - + exclude_files = (self.context or {}).get("exclude_files", False) if exclude_files: return all_files @@ -695,7 +697,7 @@ def get_data_files_info(self, obj: dict[str, Any]) -> dict[str, Any]: drf_size += data_files["total_size"] result: dict[str, Any] = { - "count": drf_count if drf_count else total_count, + "count": drf_count or total_count, "total_size": total_size, "total_count": total_count, } diff --git a/gateway/sds_gateway/api_methods/serializers/dataset_serializers.py b/gateway/sds_gateway/api_methods/serializers/dataset_serializers.py index d99eb826e..9f57ceffa 100644 --- a/gateway/sds_gateway/api_methods/serializers/dataset_serializers.py +++ b/gateway/sds_gateway/api_methods/serializers/dataset_serializers.py @@ -59,13 +59,17 @@ def get_is_shared_with_me(self, obj): """Check if the dataset is shared with the current user.""" request = (self.context or {}).get("request") if request and hasattr(request, "user"): - return UserSharePermission.objects.filter( - shared_with=request.user, - item_type=ItemType.DATASET, - item_uuid=obj.uuid, - is_enabled=True, - is_deleted=False, - ).exclude(owner=request.user).exists() + return ( + UserSharePermission.objects.filter( + shared_with=request.user, + item_type=ItemType.DATASET, + item_uuid=obj.uuid, + is_enabled=True, + is_deleted=False, + ) + .exclude(owner=request.user) + .exists() + ) return False def get_is_owner(self, obj): diff --git a/gateway/sds_gateway/templates/users/components/capture_details_modal_body.html b/gateway/sds_gateway/templates/users/components/capture_details_modal_body.html index cbf19bf8b..5c1536de1 100644 --- a/gateway/sds_gateway/templates/users/components/capture_details_modal_body.html +++ b/gateway/sds_gateway/templates/users/components/capture_details_modal_body.html @@ -18,7 +18,7 @@
value="{{ capture.name|default:'' }}" placeholder="Enter capture name" maxlength="255" - data-uuid="{{ capture_uuid }}"> + data-uuid="{{ capture_uuid }}" /> diff --git a/gateway/sds_gateway/templates/users/partials/upload_result_modal.html b/gateway/sds_gateway/templates/users/partials/upload_result_modal.html index 03be2abb1..b74f2e431 100644 --- a/gateway/sds_gateway/templates/users/partials/upload_result_modal.html +++ b/gateway/sds_gateway/templates/users/partials/upload_result_modal.html @@ -1,24 +1,24 @@ diff --git a/gateway/sds_gateway/users/forms.py b/gateway/sds_gateway/users/forms.py index c924a8cae..ffa7d1cb4 100644 --- a/gateway/sds_gateway/users/forms.py +++ b/gateway/sds_gateway/users/forms.py @@ -286,10 +286,11 @@ def clean(self): status = cleaned_data.get("status") or DatasetStatus.DRAFT is_public = cleaned_data.get("is_public", False) if is_public and status != DatasetStatus.FINAL: - raise ValidationError( + _msg = ( "Draft datasets cannot be made public. Set status to Final on the " - "Publishing step before making the dataset public.", + "Publishing step before making the dataset public." ) + raise ValidationError(_msg) return cleaned_data diff --git a/gateway/sds_gateway/users/tests/test_views.py b/gateway/sds_gateway/users/tests/test_views.py index a38d73df1..91f8a61ee 100644 --- a/gateway/sds_gateway/users/tests/test_views.py +++ b/gateway/sds_gateway/users/tests/test_views.py @@ -950,9 +950,7 @@ def client(self) -> Client: def user(self) -> User: return cast("User", UserFactory(is_approved=True)) - def test_anonymous_redirects_to_login( - self, client: Client, user: User - ) -> None: + def test_anonymous_redirects_to_login(self, client: Client, user: User) -> None: cap = CaptureFactory(owner=user) url = reverse( "users:details_modal_fragment", @@ -1105,9 +1103,7 @@ def test_dataset_modal_title_includes_version( payload = client.get(url).json() assert payload["title"] == "My DS (v7)" - def test_shared_private_dataset_modal_ok( - self, client: Client, user: User - ) -> None: + def test_shared_private_dataset_modal_ok(self, client: Client, user: User) -> None: owner = cast("User", UserFactory(is_approved=True)) ds = DatasetFactory(owner=owner, is_public=False, keywords=None) UserSharePermissionFactory( diff --git a/gateway/sds_gateway/users/urls.py b/gateway/sds_gateway/users/urls.py index fde3748f4..cce217268 100644 --- a/gateway/sds_gateway/users/urls.py +++ b/gateway/sds_gateway/users/urls.py @@ -9,6 +9,7 @@ from .views import FilesView from .views import ListCapturesView from .views import UploadCaptureView +from .views import details_modal_fragment_view from .views import generate_api_key_form_view from .views import keyword_autocomplete_api_view from .views import new_api_key_view @@ -17,7 +18,6 @@ from .views import revoke_api_key_view from .views import user_api_key_view from .views import user_captures_api_view -from .views import details_modal_fragment_view from .views import user_dataset_details_view from .views import user_dataset_list_view from .views import user_dataset_versioning_view diff --git a/gateway/sds_gateway/users/views/__init__.py b/gateway/sds_gateway/users/views/__init__.py index fc30a8dc7..68253a5dc 100644 --- a/gateway/sds_gateway/users/views/__init__.py +++ b/gateway/sds_gateway/users/views/__init__.py @@ -42,6 +42,9 @@ from .datasets import user_publish_dataset_view from .datasets import user_search_datasets_view +# Utility views +from .details_modal import details_modal_fragment_view + # Download views from .downloads import DownloadItemView from .downloads import TemporaryZipDownloadView @@ -83,9 +86,6 @@ from .user_profile import user_detail_view from .user_profile import user_redirect_view from .user_profile import user_update_view - -# Utility views -from .details_modal import details_modal_fragment_view from .utilities import RenderHTMLFragmentView from .utilities import render_html_fragment_view @@ -96,7 +96,6 @@ "CheckFileExistsView", "DatasetDetailsView", "DatasetVersioningView", - "details_modal_fragment_view", "DownloadItemView", "FileContentView", "FileDetailView", @@ -126,6 +125,7 @@ "UserRedirectView", "UserUpdateView", "_get_captures_for_template", + "details_modal_fragment_view", "files_view", "generate_api_key_form_view", "get_active_api_key_count", diff --git a/gateway/sds_gateway/users/views/captures.py b/gateway/sds_gateway/users/views/captures.py index 76f87da7c..1973f2573 100644 --- a/gateway/sds_gateway/users/views/captures.py +++ b/gateway/sds_gateway/users/views/captures.py @@ -74,12 +74,12 @@ def _capture_list_dropdown_menu_items(row: dict[str, Any]) -> list[dict[str, Any uuid = str(row.get("uuid") or "") if not uuid: return [] - + is_owner = row.get("is_owner") permission_level = row.get("permission_level") is_contributor = permission_level == PermissionLevel.CONTRIBUTOR is_co_owner = permission_level == PermissionLevel.CO_OWNER - + items: list[dict[str, Any]] = [] if is_owner: display_name = ( diff --git a/gateway/sds_gateway/users/views/datasets.py b/gateway/sds_gateway/users/views/datasets.py index 5c8265e07..5d05fb0c3 100644 --- a/gateway/sds_gateway/users/views/datasets.py +++ b/gateway/sds_gateway/users/views/datasets.py @@ -1045,7 +1045,9 @@ def _dataset_list_dropdown_menu_items(row: dict[str, Any]) -> list[dict[str, Any permission_level = row.get("permission_level") is_contributor = permission_level == PermissionLevel.CONTRIBUTOR is_co_owner = permission_level == PermissionLevel.CO_OWNER - dataset_published = row.get("status") == DatasetStatus.FINAL and row.get("is_public") + dataset_published = row.get("status") == DatasetStatus.FINAL and row.get( + "is_public" + ) items: list[dict[str, Any]] = [] if is_owner or is_contributor or is_co_owner: @@ -1101,7 +1103,6 @@ def _dataset_list_dropdown_menu_items(row: dict[str, Any]) -> list[dict[str, Any } ) - # Web download button items.append( { @@ -1298,7 +1299,9 @@ def get(self, request, *args, **kwargs) -> HttpResponse: "sort_order": sort_order, "ajax_fragment": True, "asset_type": "dataset", - "asset_row_template": "users/components/dataset_list_table_row.html", + "asset_row_template": ( + "users/components/dataset_list_table_row.html" + ), "table_headers": DATASET_LIST_TABLE_HEADERS, "table_class": DATASET_LIST_TABLE_CLASS, "no_assets_message": DATASET_LIST_NO_ASSETS_MESSAGE, @@ -1398,9 +1401,7 @@ def load_dataset_details_bundle( except Dataset.DoesNotExist: return None - has_public_access = ( - dataset.is_public and dataset.status == DatasetStatus.FINAL - ) + has_public_access = dataset.is_public and dataset.status == DatasetStatus.FINAL has_user_access = request.user.is_authenticated and user_has_access_to_item( request.user, dataset_uuid, ItemType.DATASET ) diff --git a/gateway/sds_gateway/users/views/details_modal.py b/gateway/sds_gateway/users/views/details_modal.py index cc07e1943..f955ea44f 100644 --- a/gateway/sds_gateway/users/views/details_modal.py +++ b/gateway/sds_gateway/users/views/details_modal.py @@ -2,7 +2,10 @@ from __future__ import annotations -from uuid import UUID +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from uuid import UUID from django.http import Http404 from django.http import JsonResponse @@ -18,24 +21,30 @@ class DetailsModalFragmentView(Auth0LoginRequiredMixin, View): """GET /users/details-modal/// → JSON { html, title, meta }.""" def dispatch(self, request, *args, **kwargs): - # Public published datasets: allow anonymous JSON/HTML fragment (access in registry). + # Public published datasets: allow anonymous JSON/HTML fragment + # (access in registry). if kwargs.get("asset_type") == "dataset": return View.dispatch(self, request, *args, **kwargs) return super().dispatch(request, *args, **kwargs) - def get(self, request, asset_type: str, uuid: UUID, *args, **kwargs) -> JsonResponse: + def get( + self, request, asset_type: str, uuid: UUID, *args, **kwargs + ) -> JsonResponse: builder = DETAILS_MODAL_REGISTRY.get(asset_type) json_builder = DETAILS_MODAL_JSON_BUILDERS.get(asset_type) if builder is None or json_builder is None: - raise Http404("Unknown asset type") + _unknown_asset_type = "Unknown asset type" + raise Http404(_unknown_asset_type) ctx = builder(request, uuid) if ctx is None: - raise Http404("Not found") + _builder_not_found = "Not found" + raise Http404(_builder_not_found) html = render_details_modal_body(request, asset_type, ctx) if not html: - raise Http404("Not found") + _html_not_found = "Not found" + raise Http404(_html_not_found) return JsonResponse(json_builder(ctx, html)) diff --git a/gateway/sds_gateway/users/views/downloads.py b/gateway/sds_gateway/users/views/downloads.py index 6ceb7f4dd..745795362 100644 --- a/gateway/sds_gateway/users/views/downloads.py +++ b/gateway/sds_gateway/users/views/downloads.py @@ -7,9 +7,9 @@ from django.http import HttpRequest from django.http import HttpResponse from django.http import JsonResponse -from django.urls import reverse from django.shortcuts import get_object_or_404 from django.shortcuts import render +from django.urls import reverse from django.utils import timezone from django.views import View from loguru import logger as log diff --git a/gateway/sds_gateway/users/views/files.py b/gateway/sds_gateway/users/views/files.py index adb554410..ac26b095e 100644 --- a/gateway/sds_gateway/users/views/files.py +++ b/gateway/sds_gateway/users/views/files.py @@ -1,6 +1,7 @@ import json from typing import cast +from django.conf import settings from django.db.models.query import QuerySet from django.http import HttpRequest from django.http import HttpResponse @@ -9,7 +10,6 @@ from django.shortcuts import get_object_or_404 from django.shortcuts import redirect from django.shortcuts import render -from django.conf import settings from django.views import View from django.views.generic import DetailView from loguru import logger as log @@ -36,6 +36,7 @@ from sds_gateway.users.mixins import Auth0LoginRequiredMixin from sds_gateway.users.navigation_models import NavigationContext from sds_gateway.users.navigation_models import NavigationType +from sds_gateway.visualizations.config import get_visualization_compatibility class FileDetailView(Auth0LoginRequiredMixin, DetailView): # pyright: ignore[reportMissingTypeArgument] @@ -269,8 +270,6 @@ def get(self, request, *args, **kwargs) -> HttpResponse: "user_email": request.user.email, } if settings.VISUALIZATIONS_ENABLED: - from sds_gateway.visualizations.config import get_visualization_compatibility - ctx["visualization_compatibility"] = get_visualization_compatibility() return render( diff --git a/gateway/uv.lock b/gateway/uv.lock index 6f94023c3..5446c36f7 100644 --- a/gateway/uv.lock +++ b/gateway/uv.lock @@ -157,30 +157,30 @@ wheels = [ [[package]] name = "boto3" -version = "1.43.13" +version = "1.43.14" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/f3/40abb5e3df93f31b3e7c6ca334e82dff584f9afeeed73d7ad9b2640b926a/boto3-1.43.13.tar.gz", hash = "sha256:bd909b509c459e784dcfcafb3e130cf2891ab26d2d323003bcddaf15a948c9e8", size = 113188, upload-time = "2026-05-21T21:38:15.952Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/4b/616367e871ce3f1cb3e8545a97736b6331b9fb081497f2d44c5b2aa6959d/boto3-1.43.14.tar.gz", hash = "sha256:5c0a994b3182061ee101812e721100717a4d664f9f4ceaf4a86b6d032ce9fc2d", size = 113142, upload-time = "2026-05-22T19:28:47.861Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/40/6ced1cd7c9ee81b1fa4b334ea90f05760c86463ad7ee34d44b06dd810b35/boto3-1.43.13-py3-none-any.whl", hash = "sha256:c156ba7b35687379c28f6b7216f06b477b033eab318ac70697128e99d4bfd7b7", size = 140536, upload-time = "2026-05-21T21:38:14.423Z" }, + { url = "https://files.pythonhosted.org/packages/cb/00/59cb9329c18e2d3aa23062ceaa87d065f2e81e7d2931df24d64e9a7815aa/boto3-1.43.14-py3-none-any.whl", hash = "sha256:574335744656cfed0b362a0a0467aaf2eb2bf15526edcd02d31d3c661f4b09e4", size = 140536, upload-time = "2026-05-22T19:28:46.49Z" }, ] [[package]] name = "botocore" -version = "1.43.13" +version = "1.43.14" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/34/58790c6d2e8e074e7a6286ec9d41c26237edd453c573aaf613eb621d8ae9/botocore-1.43.13.tar.gz", hash = "sha256:10df003c71847b4f1501b98b1c03e1cb6399583b6cc5136ca7ff849e00c4797f", size = 15378168, upload-time = "2026-05-21T21:38:03.89Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/3c/798d2f7deb118241930c7c6bcfb0b970d3f0245bf580700663199aeed2c3/botocore-1.43.14.tar.gz", hash = "sha256:b9e500737e43d2f147c9d4e23b54360335e77d4c0ba90a318f51b65e06cb8516", size = 15382604, upload-time = "2026-05-22T19:28:36.363Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/03/cde5fbd9a5434923ca645df067123934cb78c19ca28c57dcfda34fd8d632/botocore-1.43.13-py3-none-any.whl", hash = "sha256:c0fe4ba2d4ee35751f539ae8164da73218e1b8cf3114affd3a5312ba66b9df2e", size = 15058183, upload-time = "2026-05-21T21:37:58.648Z" }, + { url = "https://files.pythonhosted.org/packages/27/7e/6e64821077cd2efc4aa51b7d638fb6d48e1c7c450201c529fbaf1de8bfd3/botocore-1.43.14-py3-none-any.whl", hash = "sha256:1f4a2a95ea78c10398e78431e98c1fe47adb54a7b10a32975144c1f541186658", size = 15061424, upload-time = "2026-05-22T19:28:32.682Z" }, ] [[package]] @@ -748,7 +748,7 @@ boto3 = [ [[package]] name = "django-stubs" -version = "6.0.4" +version = "6.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, @@ -756,9 +756,9 @@ dependencies = [ { name = "types-pyyaml" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f9/82/ccf2a2dc9cdb4bd9cbe91f11e887589bf2da7609506db00ccbc73bd8a6da/django_stubs-6.0.4.tar.gz", hash = "sha256:7aee77e8de9c14c0d9cf84988befe826d93cbc15a87e0ade2943f14d553451cf", size = 280019, upload-time = "2026-05-09T21:24:30.436Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8a/8946216758eb66c5700a235e230af538d4ea474244c79452159b580425ba/django_stubs-6.0.5.tar.gz", hash = "sha256:7742b8e60afc68a8545158e2bdb103376d5a092f7902c17f370920a9c08eb957", size = 280490, upload-time = "2026-05-25T08:47:02.291Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/e7/5128914ada94dd6277626ef5a4a5680a4def7d2f9366214d26c1cd86723b/django_stubs-6.0.4-py3-none-any.whl", hash = "sha256:e991c68f77239663577a5f4fc75e99c84f867f378cafc97cbf4acc5aff378279", size = 543791, upload-time = "2026-05-09T21:24:28.218Z" }, + { url = "https://files.pythonhosted.org/packages/c6/09/64ff51a4cf4e8bdf8423d249b32eca0676e69233009ce9cd5478ba2c9635/django_stubs-6.0.5-py3-none-any.whl", hash = "sha256:9fb9eede9b2fc47b36c3dc4a93652eb959dff45466ec4a580e4a22782192d746", size = 544132, upload-time = "2026-05-25T08:47:00.332Z" }, ] [package.optional-dependencies] @@ -768,15 +768,15 @@ compatible-mypy = [ [[package]] name = "django-stubs-ext" -version = "6.0.4" +version = "6.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/b0/94335dc59138483c2bd2edf81cb39240fdef5e72c8cf0a6c177db207617b/django_stubs_ext-6.0.4.tar.gz", hash = "sha256:ff21f7b4362928b56e18cda0595f296e33c665f3019f4e3e4231977385e76cac", size = 6684, upload-time = "2026-05-09T21:24:02.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/01/419bc3cd882f3ec645d5a4511085202dd6bd3ef8d385871dcd2d32cc15b3/django_stubs_ext-6.0.5.tar.gz", hash = "sha256:1cc325e991303849bce70e19748981b225ef08b37256f263e113caf97cd3bcc0", size = 6666, upload-time = "2026-05-25T08:46:36.012Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/42/7db8470c0e276e7c7763c468441381dfa8727a360c68473f33ef828422bb/django_stubs_ext-6.0.4-py3-none-any.whl", hash = "sha256:0434a912bb08a370afcac9e90305c53e6f4eed3c1d1d46962559da5f8dbb8f27", size = 10373, upload-time = "2026-05-09T21:24:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bf/7ee71071d84ad4e0efcd77e2681afed254a5f65c27524441a9caf8c60467/django_stubs_ext-6.0.5-py3-none-any.whl", hash = "sha256:a9932c8233d6dd4e34ae0b192026379c1a9be8f0b7c27aa1fa09ae215169773e", size = 10362, upload-time = "2026-05-25T08:46:34.467Z" }, ] [[package]] @@ -1406,7 +1406,7 @@ wheels = [ [[package]] name = "mypy" -version = "2.0.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ast-serialize" }, @@ -1415,16 +1415,16 @@ dependencies = [ { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/dc/7e6d49f04fca40b9dd5c752a51a432ffe67fb45200702bc9eee0cb4bbb26/mypy-2.0.0.tar.gz", hash = "sha256:1a9e3900ac5c40f1fe813506c7739da6e6f0eab2729067ebd94bfb0bbba53532", size = 3869036, upload-time = "2026-05-06T19:26:43.22Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/c6/996a1e535e5d0d597c3b1460fc962733091f885f312e749350eb2ac10965/mypy-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ef5f581b61240d1cc629b12f8df6565ed6ffac0d82ed745eef7833222ab50b9", size = 14737259, upload-time = "2026-05-06T19:20:23.081Z" }, - { url = "https://files.pythonhosted.org/packages/94/c5/0f9460e26b77f434bd53f47d1ce32a3cd4580c92a5331fa5dfc059f9421a/mypy-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20e3470a165dbc249bdfbe8d1c5172727ef22688cffc279f8c3aa264ab9d4d9a", size = 13538377, upload-time = "2026-05-06T19:21:08.804Z" }, - { url = "https://files.pythonhosted.org/packages/b2/3e/8ea2f8dd1e5c9c279fb3c28193bdb850adf4d3d8172880abad829eced609/mypy-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:224ba142eee8b4d65d4db657cb1fc22abec30b135ded6ab297302ba1f62e505d", size = 13914264, upload-time = "2026-05-06T19:24:12.875Z" }, - { url = "https://files.pythonhosted.org/packages/be/ce/78bd3b8520f676acee9dab48ea71473e68f6d5cf14b59fbd800bea50a92b/mypy-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e879ad8a03908ff74d15e8a9b42bf049918e6798d52c011011f1873d0b5877e", size = 14926761, upload-time = "2026-05-06T19:20:12.846Z" }, - { url = "https://files.pythonhosted.org/packages/61/ef/b52fa340522da3d22e669117c3b83155c2660f7cdc035856958fbfffb224/mypy-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:65c5c15bcbd18d6fe927cc55c459597a3517d69cc3123f067be3b020010e115e", size = 15157014, upload-time = "2026-05-06T19:25:49.78Z" }, - { url = "https://files.pythonhosted.org/packages/7a/0c/dde7614250c6d017936c7aa3bb63b9b52c7cfd298d3f1be9be45f307870b/mypy-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:d1a068acd7c9fb77e9f8923f1556f2f49d6d7895821121b8d97fa5642b9c52f5", size = 11067049, upload-time = "2026-05-06T19:21:16.116Z" }, - { url = "https://files.pythonhosted.org/packages/27/ec/1d6af4830a94a285442db19caa02f160cc1a255e4f324eec5458e6c2bafb/mypy-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:ef9d96da1ddffbc21f27d3939319b6846d12393baa17c4d2f3e81e040e73ce2c", size = 9967903, upload-time = "2026-05-06T19:22:15.52Z" }, - { url = "https://files.pythonhosted.org/packages/5c/14/fd0694aa594d6e9f9fd16ce821be2eff295197a273262ef56ddcc1388d68/mypy-2.0.0-py3-none-any.whl", hash = "sha256:8a92b2be3146b4fa1f062af7eb05574cbf3e6eb8e1f14704af1075423144e4e5", size = 2673434, upload-time = "2026-05-06T19:26:32.856Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, ] [[package]] @@ -2412,15 +2412,15 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.47.0" +version = "0.48.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582, upload-time = "2026-05-14T18:16:54.455Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/bf/f6544ba992ddb9a6077343a576f9844f7f8f06ab819aefd00206e9255f18/uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37", size = 91074, upload-time = "2026-05-24T12:08:41.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" }, + { url = "https://files.pythonhosted.org/packages/01/be/72532be3da7acc5fdfbccdb95215cd04f995a0886532a5b423f929cda4cc/uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad", size = 71410, upload-time = "2026-05-24T12:08:40.258Z" }, ] [[package]] diff --git a/sdk/docs/.markdownlintignore b/sdk/docs/.markdownlintignore new file mode 100644 index 000000000..e33b51e81 --- /dev/null +++ b/sdk/docs/.markdownlintignore @@ -0,0 +1,2 @@ +sdk/docs/mkdocs/getting-started.md +sdk/docs/README.md From b988ec022fab7560763341a4fcdce69b65fd73d9 Mon Sep 17 00:00:00 2001 From: Lucas Parzianello Date: Mon, 1 Jun 2026 10:30:50 -0400 Subject: [PATCH 2/5] more formatting --- .github/agent.instructions.md | 20 +- .markdownlint.yaml | 47 - .pre-commit-config.yaml | 20 +- .rumdl.toml | 40 + .vscode/settings.json | 2 +- gateway/.fallowrc.json | 14 +- gateway/.vscode/settings.json | 1 - gateway/compose.local.yaml | 2 +- gateway/docs/detailed-deploy.md | 8 +- gateway/package-lock.json | 171 +- gateway/scripts/fallow-cross-file-dupes.sh | 2 +- .../scripts/fallow-static-js-dead-code.cjs | 38 +- .../static/css/admin_environment.css | 50 +- gateway/sds_gateway/static/css/alerts.css | 64 +- gateway/sds_gateway/static/css/components.css | 1718 ++++---- gateway/sds_gateway/static/css/custom.css | 200 +- gateway/sds_gateway/static/css/file-list.css | 608 +-- .../sds_gateway/static/css/file-manager.css | 1138 +++--- gateway/sds_gateway/static/css/layout.css | 240 +- .../static/css/permission-levels.css | 488 +-- .../static/css/spectrumx_theme.css | 1162 +++--- .../static/css/user_api_key_table.css | 392 +- gateway/sds_gateway/static/css/variables.css | 166 +- .../static/css/visualizations/spectrogram.css | 154 +- .../visualizations/visualization_modal.css | 62 +- .../css/visualizations/visualizations.css | 40 +- .../static/css/visualizations/waterfall.css | 342 +- .../js/__tests__/helpers/actionTestMocks.js | 353 +- .../js/actions/CaptureListSelectionManager.js | 114 +- .../static/js/actions/DetailsActionManager.js | 135 +- .../js/actions/DownloadActionManager.js | 843 ++-- .../js/actions/DownloadInstructionsManager.js | 115 +- .../static/js/actions/PublishActionManager.js | 633 +-- .../js/actions/QuickAddToDatasetManager.js | 556 +-- .../static/js/actions/ShareActionManager.js | 2037 +++++----- .../js/actions/VersioningActionManager.js | 312 +- .../__tests__/DetailsActionManager.test.js | 54 +- .../__tests__/DownloadActionManager.test.js | 1214 +++--- .../DownloadInstructionsManager.test.js | 102 +- .../__tests__/PublishActionManager.test.js | 342 +- .../QuickAddToDatasetManager.test.js | 138 +- .../__tests__/ShareActionManager.test.js | 1015 ++--- .../__tests__/VersioningActionManager.test.js | 682 ++-- .../js/actions/__tests__/quickAddApi.test.js | 50 +- .../details/AssetDetailsModalLoader.js | 278 +- .../details/CaptureDetailsModalBehavior.js | 495 +-- .../__tests__/AssetDetailsModalLoader.test.js | 531 +-- .../CaptureDetailsModalBehavior.test.js | 195 +- .../actions/download/captureDownloadSlider.js | 644 +-- .../static/js/actions/quickAdd/quickAddApi.js | 136 +- .../static/js/constants/FileListConfig.js | 22 +- .../static/js/constants/PermissionLevels.js | 26 +- .../__tests__/detailsModalConfig.test.js | 112 +- .../static/js/constants/detailsModalConfig.js | 154 +- .../sds_gateway/static/js/core/APIClient.js | 828 ++-- .../sds_gateway/static/js/core/BaseManager.js | 336 +- .../sds_gateway/static/js/core/DOMUtils.js | 1413 +++---- .../static/js/core/ModalManager.js | 573 +-- .../static/js/core/PageController.js | 169 +- .../sds_gateway/static/js/core/PageGate.js | 284 +- .../static/js/core/PageLifecycleManager.js | 1127 +++--- .../static/js/core/PermissionsManager.js | 611 +-- .../static/js/core/UserInputController.js | 280 +- .../js/core/__tests__/APIClient.test.js | 786 ++-- .../static/js/core/__tests__/DOMUtils.test.js | 2544 ++++++------ .../core/__tests__/ListRefreshManager.test.js | 152 +- .../js/core/__tests__/ModalManager.test.js | 269 +- .../js/core/__tests__/PageController.test.js | 98 +- .../__tests__/PageLifecycleManager.test.js | 1033 ++--- .../core/__tests__/PermissionsManager.test.js | 560 +-- .../static/js/dataset/AuthorsManager.js | 466 +-- .../static/js/dataset/DatasetAuthorsUI.js | 633 +-- .../js/dataset/DatasetCreationHandler.js | 2471 ++++++------ .../js/dataset/DatasetEditingHandler.js | 2582 ++++++------ .../static/js/dataset/DatasetModeManager.js | 2655 +++++++------ .../js/dataset/DatasetPendingChanges.js | 132 +- .../__tests__/DatasetCreationHandler.test.js | 1213 +++--- .../__tests__/DatasetEditingHandler.test.js | 823 ++-- .../__tests__/DatasetModeManager.test.js | 834 ++-- .../static/js/dataset/datasetFormSnapshot.js | 124 +- .../static/js/search/AssetSearchHandler.js | 3006 +++++++------- .../static/js/search/KeywordChipInput.js | 885 ++--- .../__tests__/AssetSearchHandler.test.js | 880 +++-- .../__tests__/DatasetSearchHandler.test.js | 1007 ++--- .../search/__tests__/KeywordChipInput.test.js | 1451 +++---- .../static/js/share/ShareGroupManager.js | 2551 ++++++------ .../static/js/share/UserSearchDropdown.js | 154 +- .../share/__tests__/ShareGroupManager.test.js | 480 +-- .../__tests__/UserSearchDropdown.test.js | 86 +- .../static/js/tests-config/jest.config.js | 126 +- .../static/js/tests-config/jest.setup.js | 478 +-- .../static/js/tests-config/testHelpers.js | 752 ++-- .../static/js/upload/CaptureTypeSelector.js | 288 +- .../js/upload/CaptureUploadController.js | 1576 ++++---- .../static/js/upload/FileUploadHandler.js | 380 +- .../static/js/upload/FilesBrowserManager.js | 2111 +++++----- .../static/js/upload/UploadUtils.js | 348 +- .../__tests__/CaptureUploadController.test.js | 109 +- .../js/upload/__tests__/UploadUtils.test.js | 92 +- .../static/js/upload/uploadBootstrap.js | 48 +- gateway/sds_gateway/static/js/vendors.js | 6 +- .../visualizations/common/asyncJobPolling.js | 20 +- .../visualizations/processingErrorMessages.js | 80 +- .../spectrogram/SpectrogramControls.js | 486 +-- .../spectrogram/SpectrogramRenderer.js | 268 +- .../spectrogram/SpectrogramVisualization.js | 1057 ++--- .../visualizations/spectrogram/constants.js | 38 +- .../js/visualizations/spectrogram/index.js | 28 +- .../js/visualizations/visualizationModal.js | 422 +- .../waterfall/PeriodogramChart.js | 403 +- .../waterfall/WaterfallControls.js | 1226 +++--- .../waterfall/WaterfallRenderer.js | 896 ++--- .../waterfall/WaterfallSliceCache.js | 481 +-- .../waterfall/WaterfallSliceLoader.js | 960 ++--- .../waterfall/WaterfallVisualization.js | 3504 +++++++++-------- .../__tests__/WaterfallControls.test.js | 739 ++-- .../__tests__/WaterfallRenderer.test.js | 512 +-- .../__tests__/WaterfallSliceCache.test.js | 564 +-- .../__tests__/WaterfallSliceLoader.test.js | 981 ++--- .../__tests__/WaterfallVisualization.test.js | 711 ++-- .../js/visualizations/waterfall/constants.js | 94 +- .../js/visualizations/waterfall/index.js | 34 +- .../waterfall/waterfallColorMaps.js | 184 +- .../users/partials/web_download_modal.html | 10 +- gateway/webpack/common.config.js | 72 +- gateway/webpack/dev.config.js | 48 +- gateway/webpack/prod.config.js | 20 +- jupyter/.vscode/settings.json | 2 +- network/.vscode/settings.json | 3 +- sdk/.vscode/extensions.json | 2 +- sdk/.vscode/settings.json | 94 +- sdk/docs/mkdocs/advanced/concurrent-access.md | 4 +- .../common-workflows/capture-uploads.md | 12 +- .../common-workflows/dataset-downloads.md | 6 +- sdk/docs/mkdocs/faq.md | 60 +- sdk/tests/.gitignore | 2 + .../reference-v0-addendum.rh.json | 56 +- .../radiohound-update/reference-v0.rh.json | 68 +- .../captures/radiohound/reference-v0.rh.json | 68 +- 139 files changed, 36760 insertions(+), 35742 deletions(-) delete mode 100644 .markdownlint.yaml create mode 100644 .rumdl.toml create mode 100644 sdk/tests/.gitignore diff --git a/.github/agent.instructions.md b/.github/agent.instructions.md index 3056861ff..2cf369d9c 100644 --- a/.github/agent.instructions.md +++ b/.github/agent.instructions.md @@ -5,12 +5,12 @@ These instructions are to be included in addition to your usual review guideline ## Agent focus and use cases + Your review must also assist a human reviewer by highlighting the areas that need - attention, not limited to: + attention, not limited to: + Security bounds and vulnerabilities, such as injection flaws, broken - authentication, sensitive data exposure, XML external entities (XXE), broken - access control, security misconfigurations, cross-site scripting (XSS), - insecure deserialization, using components with known vulnerabilities, and - insufficient logging and monitoring. + authentication, sensitive data exposure, XML external entities (XXE), broken + access control, security misconfigurations, cross-site scripting (XSS), + insecure deserialization, using components with known vulnerabilities, and + insufficient logging and monitoring. + Subtle bugs and logic errors that static checkers might miss. + Potential performance bottlenecks. + Antipatterns and code smells. @@ -21,19 +21,19 @@ Create a response with the following sections: 1. A summary of the changes made in the pull request. 2. A summary of the project's context, architecture, and design patterns that relate to - the changes. + the changes. 3. Identification of potential issues in the code, as per criteria above. 4. Recommendations for rewrite and refactoring, if applicable. + Focus on critical issues, and then, on minimizing maintenance costs. + Search for refactoring opportunities: almost every PR will have them. Look for: + Code reuse: not only within a PR, but code already in the codebase that could - be reused or modified instead of re-written. + be reused or modified instead of re-written. + Data structures: enforce use of type hints and data structures consistently - (e.g. Pydantic classes in Python). + (e.g. Pydantic classes in Python). + Long methods and functions: recommend splitting them. + Scope reduction: prefer functions over methods. + Prefer composition over inheritance. + Simplification: complex functions, classes, or modules that could be - simplified. + simplified. 5. Additional comments, suggestions, and nit-picks that could help improve the code - quality and reliability. + quality and reliability. diff --git a/.markdownlint.yaml b/.markdownlint.yaml deleted file mode 100644 index ae9ed9462..000000000 --- a/.markdownlint.yaml +++ /dev/null @@ -1,47 +0,0 @@ ---- -# Example markdownlint configuration file: -# https://github.com/DavidAnson/markdownlint/blob/main/schema/.markdownlint.yaml -# also works with rumdl, which is faster: https://github.com/rvben/rumdl -default: true -no-hard-tabs: true - -# TIP: set any block to "false" to disable it - -# TIP: on VS Code, use Ctrl+Space to see available rules and properties - -# MD013: Line length -line-length: false -# line-length: -# line_length: 119 -# heading_line_length: 119 -# code_block_line_length: 119 -# code_blocks: false -# tables: false -# headings: false -# strict: false -# stern: false - -# MD024: Multiple headers with the same content -no-duplicate-heading: - siblings_only: true - -# MD029: Ordered list item prefix -ol-prefix: - style: one_or_ordered - -# MD030: Spaces after list markers -ul-indent: - indent: 4 - start_indented: false - start_indent: 4 - -# MD004: Header style -ul-style: - style: consistent - -# MD046: Code block style (consistent per file) -code-block-style: - style: consistent - -# MD059: Link text should be descriptive -descriptive-link-text: false diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 515929454..5cdea7001 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ exclude: "^docs/|/migrations/|devcontainer.json" -default_stages: [ pre-commit ] +default_stages: [pre-commit] default_language_version: python: python3.13 @@ -19,7 +19,7 @@ repos: - id: check-yaml - id: debug-statements - id: check-added-large-files - args: [ "--maxkb=1024" ] + args: ["--maxkb=1024"] - id: check-builtin-literals - id: check-case-conflict - id: check-docstring-first @@ -47,14 +47,14 @@ repos: rev: v2.4.15 hooks: - id: biome-check - additional_dependencies: [ "@biomejs/biome@^1.9.4" ] + additional_dependencies: ["@biomejs/biome@^1.9.4"] # automatically upgrades Django code to migrates patterns and avoid deprecation warnings - repo: https://github.com/adamchainz/django-upgrade rev: "1.30.0" hooks: - id: django-upgrade - args: [ "--target-version", "4.2" ] + args: ["--target-version", "4.2"] # runs the ruff linter and formatter - repo: https://github.com/astral-sh/ruff-pre-commit @@ -70,8 +70,8 @@ repos: # could be applied to the gateway side. [ # --verbose, # for debugging - # --show-files, # for debugging - # --show-fixes, # for debugging + # --show-files, # for debugging + # --show-fixes, # for debugging --no-cache, --fix, --exit-non-zero-on-fix, @@ -80,7 +80,7 @@ repos: ] # --show-settings, # for debugging # formatter - id: ruff-format # runs ruff format --force-exclude - args: [ --config, "sdk/pyproject.toml" ] + args: [--config, "sdk/pyproject.toml"] # linter for django templates - repo: https://github.com/Riverside-Healthcare/djLint @@ -89,13 +89,13 @@ repos: - id: djlint-reformat-django - id: djlint-django - # linter for markdown files - see .markdownlint.yaml to configure rules + # linter for markdown files - see .rumdl.toml to configure rules - repo: https://github.com/rvben/rumdl-pre-commit rev: v0.2.0 hooks: - id: rumdl # Lint only (fails on issues) - id: rumdl-fmt # Auto-format and fail if issues remain - stages: [ pre-commit ] + stages: [pre-commit] - repo: local # add deptry rule exceptions to pyproject.toml [tool.deptry.ignore_rules] @@ -106,7 +106,7 @@ repos: entry: bash -c "cd sdk && uv run deptry .; cd ../gateway && uv run deptry ." language: system pass_filenames: false # run once per commit, not per file - types: [ python ] + types: [python] - id: fallow-cross-file-dupes name: fallow cross-file dupes (gateway static js) entry: bash gateway/scripts/fallow-cross-file-dupes.sh diff --git a/.rumdl.toml b/.rumdl.toml new file mode 100644 index 000000000..06a7e3e02 --- /dev/null +++ b/.rumdl.toml @@ -0,0 +1,40 @@ +# Based on: +# https://github.com/rvben/rumdl/blob/main/rumdl.toml.example + +# Global configuration options +[global] + exclude = [ + # e.g. exclude any obsidian-style files + "sdk/docs/mkdocs/api/client.md", + "sdk/docs/mkdocs/api/models.md", + ] + include = ["**/*.md", "**/*.mdc", "**/*.gfm"] + # Globally disable specific rules based on .markdownlint.json + disable = [ + "MD013", # line length + "MD024", # no duplicate heading + "MD025", # multiple top-level headings in the same document + "MD033", # no inline HTML + "MD077", # list continuation content indentation + ] + respect-gitignore = true + +[MD007] + # https://rumdl.dev/md007/ + # Unordered list indentation + indent = 4 + start-indented = false + # style = "fixed" # indentation style: "text-aligned" (default) | "fixed" + +[MD073] + # https://rumdl.dev/md073/ + # ToC validation + enabled = true # whether to validate the TOC (default: false) + enforce-order = true # whether TOC order must match document order (default: true) + max-level = 4 # max heading level to include (default: 6) + min-level = 2 # min heading level to include (default: 4) + # indent = 4 # spaces per indentation level (default: from MD007, or 2) + +[MD046] + # https://rumdl.dev/md046 + style = "fenced" # "consistent" | "fenced" | "indented" diff --git a/.vscode/settings.json b/.vscode/settings.json index 95fa6d0e1..c2d256dbd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "python.analysis.enablePytestSupport": false + "python.analysis.enablePytestSupport": false } diff --git a/gateway/.fallowrc.json b/gateway/.fallowrc.json index ff1e56d05..acd97b8f2 100644 --- a/gateway/.fallowrc.json +++ b/gateway/.fallowrc.json @@ -1,9 +1,9 @@ { - "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json", - "ignorePatterns": [ - "sds_gateway/static/js/deprecated/**", - "compose/**", - "sds_gateway/static/css/**" - ], - "ignoreExportsUsedInFile": true + "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json", + "ignorePatterns": [ + "sds_gateway/static/js/deprecated/**", + "compose/**", + "sds_gateway/static/css/**" + ], + "ignoreExportsUsedInFile": true } diff --git a/gateway/.vscode/settings.json b/gateway/.vscode/settings.json index 0c2f00d29..f9d912de2 100644 --- a/gateway/.vscode/settings.json +++ b/gateway/.vscode/settings.json @@ -1,5 +1,4 @@ { - "markdownlint.configFile": "../.markdownlint.yaml", "python.testing.pytestEnabled": false, "python.testing.unittestEnabled": false } diff --git a/gateway/compose.local.yaml b/gateway/compose.local.yaml index 2f18cf9eb..9dd1d14e8 100644 --- a/gateway/compose.local.yaml +++ b/gateway/compose.local.yaml @@ -26,7 +26,7 @@ networks: sds-gateway-local-opensearch-net: driver: bridge sds-network-local: - # external: true # make it external if running with traefik on this machine + external: true # make it external if running with traefik on this machine # should match traefik's network name name: sds-network-local driver: bridge diff --git a/gateway/docs/detailed-deploy.md b/gateway/docs/detailed-deploy.md index 8f0026097..5cf71172c 100644 --- a/gateway/docs/detailed-deploy.md +++ b/gateway/docs/detailed-deploy.md @@ -503,7 +503,7 @@ production hosts. + `roles.yml`: Here, you can set up custom roles for users. The extensive list of allowed permissions can be found - [here](https://opensearch.org/docs/latest/security/access-control/permissions/). + [OpenSearch access control permissions documentation](https://opensearch.org/docs/latest/security/access-control/permissions/). + `roles_mapping.yml`: In this file, you can map roles to users defined in `internal_users.yml`. It is necessary to map a role directly to a user by adding @@ -616,7 +616,7 @@ Here are some useful examples of advanced queries one might want to make to the > `now` is a keyword in OpenSearch that refers to the current date and time. More information about `range` queries can be found - [here](https://opensearch.org/docs/latest/query-dsl/term/range/). + [OpenSearch range queries documentation](https://opensearch.org/docs/latest/query-dsl/term/range/). 2. Geo-bounding Box Queries @@ -647,7 +647,7 @@ Here are some useful examples of advanced queries one might want to make to the ``` More information about `geo_bounding_box` queries can be found - [here](https://opensearch.org/docs/latest/query-dsl/geo-and-xy/geo-bounding-box/). + [OpenSearch geo-bounding box queries documentation](https://opensearch.org/docs/latest/query-dsl/geo-and-xy/geo-bounding-box/). 3. Geodistance Queries @@ -672,4 +672,4 @@ Here are some useful examples of advanced queries one might want to make to the ``` More information about `geo_distance` queries can be found - [here](https://opensearch.org/docs/latest/query-dsl/geo-and-xy/geodistance/). + [OpenSearch geodistance queries documentation](https://opensearch.org/docs/latest/query-dsl/geo-and-xy/geodistance/). diff --git a/gateway/package-lock.json b/gateway/package-lock.json index d34deeb7e..9d0eb69cb 100644 --- a/gateway/package-lock.json +++ b/gateway/package-lock.json @@ -14,7 +14,7 @@ "@babel/core": "^7.25.2", "@babel/plugin-transform-runtime": "^7.25.4", "@babel/preset-env": "^7.25.4", - "@biomejs/biome": "2.0.0-beta.5", + "@biomejs/biome": "^1.9.4", "@popperjs/core": "^2.11.8", "autoprefixer": "^10.4.20", "babel-jest": "^29.7.0", @@ -1849,10 +1849,11 @@ "license": "MIT" }, "node_modules/@biomejs/biome": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.0.0-beta.5.tgz", - "integrity": "sha512-1ldO4AepieVvg4aLi1ubZkA7NsefQT2UTNssbJbDiQTGem8kCHx/PZCwLxIR6UzFpGIjh0xsDzivyVvhnmqmuA==", + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", + "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", "dev": true, + "hasInstallScript": true, "license": "MIT OR Apache-2.0", "bin": { "biome": "bin/biome" @@ -1865,20 +1866,20 @@ "url": "https://opencollective.com/biome" }, "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.0.0-beta.5", - "@biomejs/cli-darwin-x64": "2.0.0-beta.5", - "@biomejs/cli-linux-arm64": "2.0.0-beta.5", - "@biomejs/cli-linux-arm64-musl": "2.0.0-beta.5", - "@biomejs/cli-linux-x64": "2.0.0-beta.5", - "@biomejs/cli-linux-x64-musl": "2.0.0-beta.5", - "@biomejs/cli-win32-arm64": "2.0.0-beta.5", - "@biomejs/cli-win32-x64": "2.0.0-beta.5" + "@biomejs/cli-darwin-arm64": "1.9.4", + "@biomejs/cli-darwin-x64": "1.9.4", + "@biomejs/cli-linux-arm64": "1.9.4", + "@biomejs/cli-linux-arm64-musl": "1.9.4", + "@biomejs/cli-linux-x64": "1.9.4", + "@biomejs/cli-linux-x64-musl": "1.9.4", + "@biomejs/cli-win32-arm64": "1.9.4", + "@biomejs/cli-win32-x64": "1.9.4" } }, "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.0.0-beta.5.tgz", - "integrity": "sha512-pnJiaoDpwGo+ctGkMu4POcO8jgOgCErBdYbhutr+K9rxxJS+TlHLr0LR91GCEWbGV2O1oyZRFQcW21rYFoak4w==", + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", + "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", "cpu": [ "arm64" ], @@ -1893,9 +1894,9 @@ } }, "node_modules/@biomejs/cli-darwin-x64": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.0.0-beta.5.tgz", - "integrity": "sha512-WwEZpqcmsNoFpZkUFNQcbZo52WK4hLGQ0vZk3PQ8JlZ55gJsHiyhtv6aem6fVlyVCvZgpsC0sYPLE3VvFVKNAQ==", + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", + "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", "cpu": [ "x64" ], @@ -1910,9 +1911,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.0.0-beta.5.tgz", - "integrity": "sha512-lAF1de+Ki0vnq14NwDXouKkAR/iviyMNrUngSHjTGFC4z8XGVEfIw0ZMSm7fAdJZ5fAWodt9HiYmEAVs5EtHQg==", + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", + "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", "cpu": [ "arm64" ], @@ -1927,9 +1928,9 @@ } }, "node_modules/@biomejs/cli-linux-arm64-musl": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.0.0-beta.5.tgz", - "integrity": "sha512-4vxNkYx1uEt211W8hLdXddc7icRHQgYENb72g6uTd/tLVPSBvIwqUAxAOkU+9Ai1E/8R4sWy7HIxREgpuFgbNA==", + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", + "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", "cpu": [ "arm64" ], @@ -1944,9 +1945,9 @@ } }, "node_modules/@biomejs/cli-linux-x64": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.0.0-beta.5.tgz", - "integrity": "sha512-I0Pt1VHeL1mN8G7ZwV2u9AfzBd5ZKfbvHUI4x2wETUZbwcQlAu/nEzEa2LUe5HqSmnctTR36ig7RkkM9qbmIrA==", + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", + "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", "cpu": [ "x64" ], @@ -1961,9 +1962,9 @@ } }, "node_modules/@biomejs/cli-linux-x64-musl": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.0.0-beta.5.tgz", - "integrity": "sha512-nUeKGO517GtRCxziVD9les1HiCs2s2/WIVITMN9+9RRuLOko8r+T77E8ZXEmlfLOfOIOeE6z62WITqei3oNccA==", + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", + "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", "cpu": [ "x64" ], @@ -1978,9 +1979,9 @@ } }, "node_modules/@biomejs/cli-win32-arm64": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.0.0-beta.5.tgz", - "integrity": "sha512-YXW6hgbrgBcWQ1SLO69ypWlluPchgQV5C1lTG4xOcBUWdCsfYuQirM64S6Dov7SFPqsMIoFC6LlQRW+n8qAyiA==", + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", + "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", "cpu": [ "arm64" ], @@ -1995,9 +1996,9 @@ } }, "node_modules/@biomejs/cli-win32-x64": { - "version": "2.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.0.0-beta.5.tgz", - "integrity": "sha512-N7Yby52BJmvEdst1iMbclE5hxxefboaXKRJLm1tLfBYr4FeuoCe6j8HdiQSwhCRdIUGFFqBLaDXh//LLF6EReA==", + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", + "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", "cpu": [ "x64" ], @@ -3112,9 +3113,9 @@ } }, "node_modules/@fallow-cli/darwin-arm64": { - "version": "2.84.0", - "resolved": "https://registry.npmjs.org/@fallow-cli/darwin-arm64/-/darwin-arm64-2.84.0.tgz", - "integrity": "sha512-3PUPTxrwJe9oei/o8d/OIo0uC56EA1wEMxGUNrhjqn3vH4qZS5ywReFGExMeHAiPoHc3KHgACUdLjlQ1IrChAA==", + "version": "2.85.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/darwin-arm64/-/darwin-arm64-2.85.0.tgz", + "integrity": "sha512-GmV5+f6TqhQWraymXN2L4JNRPjJ3N2i9KvAzAnQsonipCCDJ7Y1QbBMCvwzLSM5e3tKXC8hRgcc5pUnwInuocg==", "cpu": [ "arm64" ], @@ -3126,9 +3127,9 @@ ] }, "node_modules/@fallow-cli/darwin-x64": { - "version": "2.84.0", - "resolved": "https://registry.npmjs.org/@fallow-cli/darwin-x64/-/darwin-x64-2.84.0.tgz", - "integrity": "sha512-KjmDjiqz9IHZcrMthEJ3VJu6cGnyKJEiwUyAG3ONgHYsnJTqGy8okDcsk8Su9ZGPQugMYOryz4S7CxdwroPVJQ==", + "version": "2.85.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/darwin-x64/-/darwin-x64-2.85.0.tgz", + "integrity": "sha512-P7dtW5WIQgN4RhhsnuLWf8hr8ySbZEPP2lBmIzCMmXZcK7uBwS3tVy4tDmY6SjQ0BmuN0e1IJRwD66BzTjh7Tg==", "cpu": [ "x64" ], @@ -3140,9 +3141,9 @@ ] }, "node_modules/@fallow-cli/linux-arm64-gnu": { - "version": "2.84.0", - "resolved": "https://registry.npmjs.org/@fallow-cli/linux-arm64-gnu/-/linux-arm64-gnu-2.84.0.tgz", - "integrity": "sha512-iMi885i1I3T3DoG+seF37V2HtXEhYIQj5lrdcfEjXEkoK9gnLIsL9H51bdax9ITQ+DAS0lOl6vsaP0bQ+1XgVw==", + "version": "2.85.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/linux-arm64-gnu/-/linux-arm64-gnu-2.85.0.tgz", + "integrity": "sha512-8WWgP+3a9bzTLzq6hu7jqcWwTqdcJMK2iq/rLV+Trz/mzY11dmIdsplJQdr1T8Pjq2s2P/Os4Des3SeST0dQeQ==", "cpu": [ "arm64" ], @@ -3154,9 +3155,9 @@ ] }, "node_modules/@fallow-cli/linux-arm64-musl": { - "version": "2.84.0", - "resolved": "https://registry.npmjs.org/@fallow-cli/linux-arm64-musl/-/linux-arm64-musl-2.84.0.tgz", - "integrity": "sha512-NADPf0e4SD6j4rsf38ywATMm7vC6PwotyZcIzLQX6TJ28845AYxTKe1o9EawAx4lMH0BIkg0SEJLnQpLUZ0zRw==", + "version": "2.85.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/linux-arm64-musl/-/linux-arm64-musl-2.85.0.tgz", + "integrity": "sha512-ZeJE5nxwvgPlbDgrjUdiMkV12s/h6e2ZGMUFiBoFGObNbHewkHA6HocfKVSWp81VX1XEiWK0Gt1aI7Zcp+REJQ==", "cpu": [ "arm64" ], @@ -3168,9 +3169,9 @@ ] }, "node_modules/@fallow-cli/linux-x64-gnu": { - "version": "2.84.0", - "resolved": "https://registry.npmjs.org/@fallow-cli/linux-x64-gnu/-/linux-x64-gnu-2.84.0.tgz", - "integrity": "sha512-plxtRxzx7n73SvdnoNSSXjKvwUKLixHGgUHB11YzM3WNOEo/2lD7PcRMFee2AAPCeCVtSa6u3IYozQCHDpduOw==", + "version": "2.85.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/linux-x64-gnu/-/linux-x64-gnu-2.85.0.tgz", + "integrity": "sha512-COeXmkc4jQxkG9GbbOm5Y/tuR87nu5Q4oAk8hwx568btN0Bhzdu/H2bTUOMb7xBD65JnsTOG4XijwTNR1dkz2w==", "cpu": [ "x64" ], @@ -3182,9 +3183,9 @@ ] }, "node_modules/@fallow-cli/linux-x64-musl": { - "version": "2.84.0", - "resolved": "https://registry.npmjs.org/@fallow-cli/linux-x64-musl/-/linux-x64-musl-2.84.0.tgz", - "integrity": "sha512-Io5Mw3NV3PwQRG6CC+tJQfGCgyzY9NDyBtjVuRtqcAI3KxVDeIXotrI4aiMN6uiA/1tsOfLGiN1ujitBPSM6LQ==", + "version": "2.85.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/linux-x64-musl/-/linux-x64-musl-2.85.0.tgz", + "integrity": "sha512-OSj/uwUAUTxHnpIemM8Vgvl8eQh1sRSGnYMIMASsghedFhG6yc+MnB8P/PND7Tfa2H4fFqsqTDsjOl1VmHEV2A==", "cpu": [ "x64" ], @@ -3196,9 +3197,9 @@ ] }, "node_modules/@fallow-cli/win32-arm64-msvc": { - "version": "2.84.0", - "resolved": "https://registry.npmjs.org/@fallow-cli/win32-arm64-msvc/-/win32-arm64-msvc-2.84.0.tgz", - "integrity": "sha512-hwJUcScgICY0omilKl6CgsKkHv1dBot/y+BLMciwYQarchdgiX8zdGBU38HxtAi2p+uPkz/KHC+iKUofjWhkrQ==", + "version": "2.85.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/win32-arm64-msvc/-/win32-arm64-msvc-2.85.0.tgz", + "integrity": "sha512-VQDmyXdVttaxuGeiEsqdhtnRg40uMfI2IPK2GXdiC9NgvabMWGDsgWlotSwUJ8pSi2ktIuLMuld0VIYCbk8oPw==", "cpu": [ "arm64" ], @@ -3210,9 +3211,9 @@ ] }, "node_modules/@fallow-cli/win32-x64-msvc": { - "version": "2.84.0", - "resolved": "https://registry.npmjs.org/@fallow-cli/win32-x64-msvc/-/win32-x64-msvc-2.84.0.tgz", - "integrity": "sha512-wALXq3YDJ9F59hvYj+pCtTZt6e4nkKDP6bmh7ZNJtpOovT2ajKmmNaL/LF1ZYuTI74s1asBY2uFpmF5eKcJ6Zw==", + "version": "2.85.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/win32-x64-msvc/-/win32-x64-msvc-2.85.0.tgz", + "integrity": "sha512-T/rhJ4KBa87hrhrY8upUVQ+oc9kGRWyVWxx84DHeElq5foNgxEut43LgrbzuP3ErIeSuA+GGnLZsGhrGiMTCUw==", "cpu": [ "x64" ], @@ -5626,9 +5627,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", - "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "version": "2.10.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", + "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -6261,10 +6262,20 @@ "license": "Python-2.0" }, "node_modules/cosmiconfig/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7191,9 +7202,9 @@ "license": "MIT" }, "node_modules/fallow": { - "version": "2.84.0", - "resolved": "https://registry.npmjs.org/fallow/-/fallow-2.84.0.tgz", - "integrity": "sha512-9k8dOnQ/7MC+OPi9GQczVToA2QsLEMHcnzE28hxJD+e29QegArllUPcNuLxHmx9x441Alkf1ffh2jJyfFSWQKA==", + "version": "2.85.0", + "resolved": "https://registry.npmjs.org/fallow/-/fallow-2.85.0.tgz", + "integrity": "sha512-R8Obl32b2LYyNtUYYtCgMXlDKlRqjcR8obVw+UhAi9pj1Zk4BIfbjy1tiEUlD7e1KkU5uC+CvrMFL+Ke73qarw==", "dev": true, "license": "MIT", "dependencies": { @@ -7208,14 +7219,14 @@ "node": ">=16" }, "optionalDependencies": { - "@fallow-cli/darwin-arm64": "2.84.0", - "@fallow-cli/darwin-x64": "2.84.0", - "@fallow-cli/linux-arm64-gnu": "2.84.0", - "@fallow-cli/linux-arm64-musl": "2.84.0", - "@fallow-cli/linux-x64-gnu": "2.84.0", - "@fallow-cli/linux-x64-musl": "2.84.0", - "@fallow-cli/win32-arm64-msvc": "2.84.0", - "@fallow-cli/win32-x64-msvc": "2.84.0" + "@fallow-cli/darwin-arm64": "2.85.0", + "@fallow-cli/darwin-x64": "2.85.0", + "@fallow-cli/linux-arm64-gnu": "2.85.0", + "@fallow-cli/linux-arm64-musl": "2.85.0", + "@fallow-cli/linux-x64-gnu": "2.85.0", + "@fallow-cli/linux-x64-musl": "2.85.0", + "@fallow-cli/win32-arm64-msvc": "2.85.0", + "@fallow-cli/win32-x64-msvc": "2.85.0" } }, "node_modules/fast-deep-equal": { @@ -9091,9 +9102,9 @@ } }, "node_modules/launch-editor": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.0.tgz", - "integrity": "sha512-Pj3ZOx9dD1BClS7YcSQx0An1PCF9wz4JpvbEmKvDxQtm0jxlkk5NhW8x0SBAKA/acHBKZaqdd5FFOWlXo500JA==", + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz", + "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/gateway/scripts/fallow-cross-file-dupes.sh b/gateway/scripts/fallow-cross-file-dupes.sh index 6ac01cf41..fcd64cca3 100755 --- a/gateway/scripts/fallow-cross-file-dupes.sh +++ b/gateway/scripts/fallow-cross-file-dupes.sh @@ -8,4 +8,4 @@ npx fallow dupes --format json -q | jq -e ' ] | length == 0 ' >/dev/null -echo "No cross-file clone groups detected." \ No newline at end of file +echo "No cross-file clone groups detected." diff --git a/gateway/scripts/fallow-static-js-dead-code.cjs b/gateway/scripts/fallow-static-js-dead-code.cjs index b39538b53..c1f8c202c 100644 --- a/gateway/scripts/fallow-static-js-dead-code.cjs +++ b/gateway/scripts/fallow-static-js-dead-code.cjs @@ -4,29 +4,29 @@ * `sds_gateway/static/js/` so reported issues are limited to that tree while * the full project graph (webpack + templates) still resolves usage. */ -const { spawnSync } = require("node:child_process"); -const fs = require("node:fs"); -const path = require("node:path"); +const { spawnSync } = require("node:child_process") +const fs = require("node:fs") +const path = require("node:path") -const root = path.join(__dirname, ".."); -const base = path.join(root, "sds_gateway/static/js"); -const deprecated = path.join(base, "deprecated") + path.sep; +const root = path.join(__dirname, "..") +const base = path.join(root, "sds_gateway/static/js") +const deprecated = path.join(base, "deprecated") + path.sep function walk(dir, out) { - for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { - const p = path.join(dir, ent.name); - if (p.startsWith(deprecated)) continue; - if (ent.isDirectory()) walk(p, out); - else if (p.endsWith(".js")) out.push(path.relative(root, p)); - } + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, ent.name) + if (p.startsWith(deprecated)) continue + if (ent.isDirectory()) walk(p, out) + else if (p.endsWith(".js")) out.push(path.relative(root, p)) + } } -const files = []; -walk(base, files); -const args = ["dead-code"]; +const files = [] +walk(base, files) +const args = ["dead-code"] for (const f of files) { - args.push("--file", f); + args.push("--file", f) } -const bin = path.join(root, "node_modules", ".bin", "fallow"); -const r = spawnSync(bin, args, { cwd: root, stdio: "inherit" }); -process.exit(r.status === null ? 1 : r.status); +const bin = path.join(root, "node_modules", ".bin", "fallow") +const r = spawnSync(bin, args, { cwd: root, stdio: "inherit" }) +process.exit(r.status === null ? 1 : r.status) diff --git a/gateway/sds_gateway/static/css/admin_environment.css b/gateway/sds_gateway/static/css/admin_environment.css index 703880f5f..0a26c766a 100644 --- a/gateway/sds_gateway/static/css/admin_environment.css +++ b/gateway/sds_gateway/static/css/admin_environment.css @@ -1,13 +1,13 @@ body.admin-env-production #header { - background: #800020; + background: #800020; } body.admin-env-staging #header { - background: #ff8c00; + background: #ff8c00; } body.admin-env-local #header { - background: #198754; + background: #198754; } body.admin-env-production #header, @@ -19,50 +19,50 @@ body.admin-env-staging #header a:visited, body.admin-env-local #header, body.admin-env-local #header a:link, body.admin-env-local #header a:visited { - color: #fff; + color: #fff; } .mono { - font-family: monospace; + font-family: monospace; } .admin-monitoring-pill { - display: inline-block; - margin-left: 1rem; - padding: 0.2rem 0.8rem; - white-space: nowrap; - border-radius: 999px; - font-size: 1rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.03rem; - background: #6c757d; - border: 1px dashed #0f2d47; + display: inline-block; + margin-left: 1rem; + padding: 0.2rem 0.8rem; + white-space: nowrap; + border-radius: 999px; + font-size: 1rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03rem; + background: #6c757d; + border: 1px dashed #0f2d47; } .admin-monitoring-pill--healthy { - background: #3fa676; + background: #3fa676; } .admin-monitoring-pill--degraded { - background: #fd7e14; + background: #fd7e14; } .admin-monitoring-pill--down { - background: #dc3545; + background: #dc3545; } .monitoring-panel { - margin-bottom: 1rem; - border: 1px solid #0f2d47; - padding: 1rem; - border-radius: 1rem; + margin-bottom: 1rem; + border: 1px solid #0f2d47; + padding: 1rem; + border-radius: 1rem; } .monitoring-muted { - opacity: 0.6; + opacity: 0.6; } .monitoring-panel h2 { - margin-top: 0; + margin-top: 0; } diff --git a/gateway/sds_gateway/static/css/alerts.css b/gateway/sds_gateway/static/css/alerts.css index 440d0b921..6cdf4df8d 100644 --- a/gateway/sds_gateway/static/css/alerts.css +++ b/gateway/sds_gateway/static/css/alerts.css @@ -1,59 +1,59 @@ /* Alert theme variables */ :root { - /* Light theme colors */ - --light-background: #fff; - --light-border-debug: #d6e9c6; - --light-border-error: #eed3d7; - --light-color-debug: #000; - --light-color-error: #b94a48; - --light-alert-dismissible: #333; - - /* Dark theme colors */ - --dark-background: #23272f; - --dark-border-debug: #3fc1a5; - --dark-border-error: #b30059; - --dark-color-debug: #f5f5f5; - --dark-color-error: #ff6f91; - --dark-alert-dismissible: #e0e0e0; + /* Light theme colors */ + --light-background: #fff; + --light-border-debug: #d6e9c6; + --light-border-error: #eed3d7; + --light-color-debug: #000; + --light-color-error: #b94a48; + --light-alert-dismissible: #333; + + /* Dark theme colors */ + --dark-background: #23272f; + --dark-border-debug: #3fc1a5; + --dark-border-error: #b30059; + --dark-color-debug: #f5f5f5; + --dark-color-error: #ff6f91; + --dark-alert-dismissible: #e0e0e0; } /* Light theme */ .theme-light .alert-debug { - background-color: var(--light-background); - border-color: var(--light-border-debug); - color: var(--light-color-debug); + background-color: var(--light-background); + border-color: var(--light-border-debug); + color: var(--light-color-debug); } .theme-light .alert-error { - background-color: var(--light-background); - border-color: var(--light-border-error); - color: var(--light-color-error); + background-color: var(--light-background); + border-color: var(--light-border-error); + color: var(--light-color-error); } .theme-light .alert-dismissible { - font-size: 1.25rem; - color: var(--light-alert-dismissible); + font-size: 1.25rem; + color: var(--light-alert-dismissible); } /* Dark theme */ .theme-dark .alert-debug { - background-color: var(--dark-background); - border-color: var(--dark-border-debug); - color: var(--dark-color-debug); + background-color: var(--dark-background); + border-color: var(--dark-border-debug); + color: var(--dark-color-debug); } .theme-dark .alert-error { - background-color: var(--dark-background); - border-color: var(--dark-border-error); - color: var(--dark-color-error); + background-color: var(--dark-background); + border-color: var(--dark-border-error); + color: var(--dark-color-error); } .theme-dark .alert-dismissible { - font-size: 1.25rem; - color: var(--dark-alert-dismissible); + font-size: 1.25rem; + color: var(--dark-alert-dismissible); } /* Generic styles outside themes */ .italic-text { - font-style: italic; + font-style: italic; } diff --git a/gateway/sds_gateway/static/css/components.css b/gateway/sds_gateway/static/css/components.css index d9510c9fa..0a9e57e52 100644 --- a/gateway/sds_gateway/static/css/components.css +++ b/gateway/sds_gateway/static/css/components.css @@ -1,1746 +1,1746 @@ /* Spinner sizes */ .spinner-border-lg { - width: 3rem; - height: 3rem; + width: 3rem; + height: 3rem; } /* Button hover states - ensure proper contrast */ .btn-outline-secondary:hover { - color: var(--bs-white); - background-color: var(--bs-gray-600); - border-color: var(--bs-gray-600); + color: var(--bs-white); + background-color: var(--bs-gray-600); + border-color: var(--bs-gray-600); } .btn-outline-secondary:focus { - color: var(--bs-white); - background-color: var(--bs-gray-600); - border-color: var(--bs-gray-600); - box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.25); + color: var(--bs-white); + background-color: var(--bs-gray-600); + border-color: var(--bs-gray-600); + box-shadow: 0 0 0 0.2rem rgba(108, 117, 125, 0.25); } /* Dataset Editing Styles */ .marked-for-removal { - text-decoration: line-through; - background-color: var(--bs-gray-100); - cursor: not-allowed; - opacity: 0.6; - color: var(--bs-gray-600); + text-decoration: line-through; + background-color: var(--bs-gray-100); + cursor: not-allowed; + opacity: 0.6; + color: var(--bs-gray-600); } .marked-for-removal td { - text-decoration: line-through; - color: var(--bs-gray-600); + text-decoration: line-through; + color: var(--bs-gray-600); } /* Authors Management Styles */ .authors-management { - border: 1px solid var(--bs-gray-300); - border-radius: 0.375rem; - padding: 1rem; - background-color: var(--bs-gray-100); + border: 1px solid var(--bs-gray-300); + border-radius: 0.375rem; + padding: 1rem; + background-color: var(--bs-gray-100); } .author-item { - margin-bottom: 0.75rem; - padding: 0.75rem; - background-color: var(--bs-white); - border: 1px solid var(--bs-gray-300); - border-radius: 0.375rem; + margin-bottom: 0.75rem; + padding: 0.75rem; + background-color: var(--bs-white); + border: 1px solid var(--bs-gray-300); + border-radius: 0.375rem; } .author-item:last-child { - margin-bottom: 0; + margin-bottom: 0; } .remove-author { - width: 32px; - height: 32px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - padding: 0; + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + padding: 0; } .author-item-number { - width: 24px; - height: 24px; - border-radius: 50%; - background-color: var(--bs-primary); - color: var(--bs-white); - display: flex; - align-items: center; - justify-content: center; - font-size: 0.75rem; - font-weight: 600; + width: 24px; + height: 24px; + border-radius: 50%; + background-color: var(--bs-primary); + color: var(--bs-white); + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; } .authors-list:empty + #add-author-btn { - margin-top: 0; + margin-top: 0; } /* Disabled button styling for marked items */ .marked-for-removal .mark-for-removal-btn { - opacity: 0.5; - cursor: not-allowed; + opacity: 0.5; + cursor: not-allowed; } .marked-for-removal .mark-for-removal-btn:disabled { - opacity: 0.5; - cursor: not-allowed; + opacity: 0.5; + cursor: not-allowed; } .current-capture-row, .current-file-row { - transition: all 0.3s ease; + transition: all 0.3s ease; } .current-capture-row:hover, .current-file-row:hover { - background-color: var(--bs-gray-100); + background-color: var(--bs-gray-100); } .pending-changes-count, .current-captures-count, .current-files-count { - font-size: 0.875rem; + font-size: 0.875rem; } /* User profile styling */ .form-control[readonly] { - background-color: var(--bs-gray-100); - border-color: var(--bs-gray-300); - color: var(--bs-gray-600); + background-color: var(--bs-gray-100); + border-color: var(--bs-gray-300); + color: var(--bs-gray-600); } .form-control[readonly]:focus { - background-color: var(--bs-gray-100); - border-color: var(--bs-gray-300); - box-shadow: none; + background-color: var(--bs-gray-100); + border-color: var(--bs-gray-300); + box-shadow: none; } /* Ensure textarea also has proper padding when disabled */ textarea.form-control[readonly], textarea.form-control:disabled, textarea.form-control-plaintext { - padding: 0.75rem 1rem; + padding: 0.75rem 1rem; } /* Author management styling */ .author-item { - transition: all 0.3s ease; + transition: all 0.3s ease; } .author-item:hover { - background-color: var(--bs-gray-100); + background-color: var(--bs-gray-100); } .author-item.marked-for-removal .form-control { - background-color: var(--bs-gray-100); - border-color: var(--bs-gray-300); - color: var(--bs-gray-600); + background-color: var(--bs-gray-100); + border-color: var(--bs-gray-300); + color: var(--bs-gray-600); } .author-name-input, .author-orcid-input { - font-size: 0.9rem; + font-size: 0.9rem; } .author-orcid-input { - font-family: "Courier New", monospace; + font-family: "Courier New", monospace; } /* Permission-based styling */ .readonly-field { - background-color: var(--bs-gray-100); - border: 1px solid var(--bs-gray-300); - color: var(--bs-gray-600); + background-color: var(--bs-gray-100); + border: 1px solid var(--bs-gray-300); + color: var(--bs-gray-600); } .readonly-field:focus { - background-color: var(--bs-gray-100); - border-color: var(--bs-gray-300); - box-shadow: none; + background-color: var(--bs-gray-100); + border-color: var(--bs-gray-300); + box-shadow: none; } .readonly-row { - background-color: var(--bs-gray-100); - opacity: 0.8; + background-color: var(--bs-gray-100); + opacity: 0.8; } .readonly-row td { - color: var(--bs-gray-600); + color: var(--bs-gray-600); } .readonly-row .badge { - opacity: 0.7; + opacity: 0.7; } /* Disabled state styling */ .disabled-element { - opacity: 0.5; - pointer-events: none; + opacity: 0.5; + pointer-events: none; } /* Authors management styling */ .author-item { - transition: all 0.2s ease; + transition: all 0.2s ease; } .author-item:hover { - background-color: var(--bs-gray-100); - padding: 0.5rem; - border-radius: 0.375rem; + background-color: var(--bs-gray-100); + padding: 0.5rem; + border-radius: 0.375rem; } .author-input[readonly] { - background-color: var(--bs-gray-100); - border-color: var(--bs-gray-300); - color: var(--bs-gray-600); - font-weight: 500; + background-color: var(--bs-gray-100); + border-color: var(--bs-gray-300); + color: var(--bs-gray-600); + font-weight: 500; } .authors-list { - min-height: 2.5rem; + min-height: 2.5rem; } #add-author-btn { - transition: all 0.2s ease; + transition: all 0.2s ease; } #add-author-btn:hover { - transform: translateY(-1px); + transform: translateY(-1px); } /* Dataset Management Layout Improvements */ .card { - border-radius: 0.5rem; - box-shadow: 0 0.125rem 0.25rem var(--shadow-black); + border-radius: 0.5rem; + box-shadow: 0 0.125rem 0.25rem var(--shadow-black); } .card-header { - border-bottom: 1px solid var(--bs-gray-300); - padding: 1rem 1.25rem; - background-color: var(--bs-gray-100); - border-top-left-radius: 0.5rem; - border-top-right-radius: 0.5rem; + border-bottom: 1px solid var(--bs-gray-300); + padding: 1rem 1.25rem; + background-color: var(--bs-gray-100); + border-top-left-radius: 0.5rem; + border-top-right-radius: 0.5rem; } .card-body { - padding: 1.25rem; + padding: 1.25rem; } .table { - margin-bottom: 0; + margin-bottom: 0; } .table th { - border-top: none; - border-bottom: 2px solid var(--bs-gray-300); - font-weight: 600; - color: var(--bs-gray-700); - padding: 0.75rem 0.5rem; + border-top: none; + border-bottom: 2px solid var(--bs-gray-300); + font-weight: 600; + color: var(--bs-gray-700); + padding: 0.75rem 0.5rem; } .table td { - padding: 0.75rem 0.5rem; - vertical-align: middle; + padding: 0.75rem 0.5rem; + vertical-align: middle; } .table-hover tbody tr:hover { - background-color: var(--bs-gray-100); + background-color: var(--bs-gray-100); } /* Side panel styling */ .col-md-4 .card { - height: fit-content; + height: fit-content; } .col-md-4 .card:first-child { - margin-bottom: 1.5rem; + margin-bottom: 1.5rem; } /* Badge styling */ .badge { - font-size: 0.75rem; - padding: 0.25rem 0.5rem; + font-size: 0.75rem; + padding: 0.25rem 0.5rem; } /* Button spacing */ .btn + .btn { - margin-left: 0.5rem; + margin-left: 0.5rem; } /* Form spacing */ .form-group { - margin-bottom: 1rem; + margin-bottom: 1rem; } .form-label { - font-weight: 500; - color: var(--bs-gray-700); - margin-bottom: 0.5rem; + font-weight: 500; + color: var(--bs-gray-700); + margin-bottom: 0.5rem; } /* Alert styling */ .alert { - border-radius: 0.5rem; - border: none; + border-radius: 0.5rem; + border: none; } .alert-info { - background-color: var(--bs-info-bg); - color: var(--bs-info-dark); + background-color: var(--bs-info-bg); + color: var(--bs-info-dark); } .alert-warning { - background-color: var(--bs-warning-bg); - color: var(--bs-warning-dark); + background-color: var(--bs-warning-bg); + color: var(--bs-warning-dark); } /* Top Gradient Bar */ .top-gradient-bar { - height: 4px; - background: linear-gradient( - to right, - var(--gradient-1), - var(--gradient-2), - var(--gradient-3), - var(--gradient-4), - var(--gradient-5), - var(--gradient-6), - var(--gradient-7) - ); - position: absolute; - top: 0; - left: 0; - right: 0; - z-index: 1000; - width: 100%; + height: 4px; + background: linear-gradient( + to right, + var(--gradient-1), + var(--gradient-2), + var(--gradient-3), + var(--gradient-4), + var(--gradient-5), + var(--gradient-6), + var(--gradient-7) + ); + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 1000; + width: 100%; } /* Hero Section */ .hero { - background-color: var(--primary-color); - color: var(--bs-white); - padding: 2rem 0 4rem; - margin-bottom: 0; - margin-top: 4px; - /* Add margin to account for gradient bar */ + background-color: var(--primary-color); + color: var(--bs-white); + padding: 2rem 0 4rem; + margin-bottom: 0; + margin-top: 4px; + /* Add margin to account for gradient bar */ } .hero-white-page .hero { - background-color: var(--bs-white); - color: var(--text-color); + background-color: var(--bs-white); + color: var(--text-color); } .hero-content a { - color: var(--secondary-color); - text-decoration: underline; + color: var(--secondary-color); + text-decoration: underline; } .hero-content a:hover { - color: var(--light-gray-text); - text-decoration: underline; + color: var(--light-gray-text); + text-decoration: underline; } .hero h1 { - font-size: 2.5rem; - margin-bottom: 3rem; - text-align: center; + font-size: 2.5rem; + margin-bottom: 3rem; + text-align: center; } .hero-content h2 { - margin-top: 0; - margin-bottom: 1.5rem; + margin-top: 0; + margin-bottom: 1.5rem; } .hero-boxes { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(500px, 1fr)); - grid-template-columns: repeat(3, 1fr); - gap: 3rem; - justify-content: center; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(500px, 1fr)); + grid-template-columns: repeat(3, 1fr); + gap: 3rem; + justify-content: center; } .hero-box { - background: var(--white-transparent); - padding: 2rem; - border-radius: 8px; - min-width: 400px; - min-height: 200px; - margin: 0 auto; - width: 100%; + background: var(--white-transparent); + padding: 2rem; + border-radius: 8px; + min-width: 400px; + min-height: 200px; + margin: 0 auto; + width: 100%; } .hero-box h2 { - font-size: 1.5rem; - margin-bottom: 1rem; + font-size: 1.5rem; + margin-bottom: 1rem; } /* Custom Table */ .custom-table { - width: 100%; - border-collapse: separate; - border-spacing: 0; - border: none; + width: 100%; + border-collapse: separate; + border-spacing: 0; + border: none; } .custom-table thead { - background-color: var(--bs-gray-100); + background-color: var(--bs-gray-100); } .custom-table thead th { - color: var(--bs-gray-700); - text-align: left; - padding: 12px; - border-top: 2px solid var(--bs-gray-300); - border-bottom: 1px solid var(--bs-gray-300); - border-right: none; + color: var(--bs-gray-700); + text-align: left; + padding: 12px; + border-top: 2px solid var(--bs-gray-300); + border-bottom: 1px solid var(--bs-gray-300); + border-right: none; } .custom-table thead th.sortable { - cursor: pointer; - transition: background-color 0.2s ease; + cursor: pointer; + transition: background-color 0.2s ease; } .custom-table thead th.sortable:hover { - background-color: var(--bs-gray-200); + background-color: var(--bs-gray-200); } .custom-table thead th.sortable a { - display: inline-flex; - /* Keep header text and arrow on one line */ - align-items: center; - gap: 0.25rem; - /* Space between text and icon */ - color: inherit; - text-decoration: none; + display: inline-flex; + /* Keep header text and arrow on one line */ + align-items: center; + gap: 0.25rem; + /* Space between text and icon */ + color: inherit; + text-decoration: none; } .custom-table thead th.sortable a:hover { - color: var(--bs-primary); + color: var(--bs-primary); } .custom-table thead th.sortable .sort-icon { - font-size: 0.75rem; - /* Adjust icon size */ - color: var(--bs-black); - /* Black arrows */ - transition: color 0.2s ease; - margin-left: 0.25rem; + font-size: 0.75rem; + /* Adjust icon size */ + color: var(--bs-black); + /* Black arrows */ + transition: color 0.2s ease; + margin-left: 0.25rem; } .custom-table tbody tr:nth-child(even) { - background-color: var(--bs-gray-100); + background-color: var(--bs-gray-100); } .custom-table tbody tr:nth-child(odd) { - background-color: var(--bs-white); + background-color: var(--bs-white); } .custom-table tbody tr td { - padding: 12px; - border: none; - color: var(--bs-gray-800); + padding: 12px; + border: none; + color: var(--bs-gray-800); } .custom-table th, .custom-table td { - border-left: none; + border-left: none; } .custom-table th:first-child, .custom-table td:first-child { - border-left: none; + border-left: none; } .custom-table th:last-child, .custom-table td:last-child { - border-right: none; + border-right: none; } .custom-table tbody tr:hover { - background-color: var(--bs-gray-200); + background-color: var(--bs-gray-200); } /* Variables */ :root { - --table-width: 100%; + --table-width: 100%; } /* Hero Content Filelist */ .hero-content-filelist { - display: flex; - justify-content: center; - align-items: center; - min-height: 200px; + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; } .hero-box-filelist { - text-align: center; - background-color: var(--bs-gray-100); - padding: 20px; - border-radius: 8px; - box-shadow: 0 2px 4px var(--shadow-black-light); + text-align: center; + background-color: var(--bs-gray-100); + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 4px var(--shadow-black-light); } .table-title { - text-align: left; - padding: 10px; - font-size: 2rem; + text-align: left; + padding: 10px; + font-size: 2rem; } /* Auth Container */ .auth-container { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - text-align: center; - padding: 2rem; - background-color: var(--white-transparent); - border-radius: 8px; - max-width: 500px; - margin: 0 auto; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + padding: 2rem; + background-color: var(--white-transparent); + border-radius: 8px; + max-width: 500px; + margin: 0 auto; } .signup-link { - margin-top: 1rem; + margin-top: 1rem; } /* Resource List */ .resource-list { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 2rem; - list-style: none; - padding: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 2rem; + list-style: none; + padding: 0; } .resources-section { - margin-top: 60px; + margin-top: 60px; } .resource-box { - background-color: var(--bs-white); - padding: 1rem; - width: 90%; - min-width: 300px; - max-width: 300px; - min-height: 150px; - margin: 0 auto; - border-radius: 8px; - display: flex; - flex-direction: column; - justify-content: space-between; - box-shadow: 0 2px 5px var(--shadow-black-light); + background-color: var(--bs-white); + padding: 1rem; + width: 90%; + min-width: 300px; + max-width: 300px; + min-height: 150px; + margin: 0 auto; + border-radius: 8px; + display: flex; + flex-direction: column; + justify-content: space-between; + box-shadow: 0 2px 5px var(--shadow-black-light); } /* Main Content Wrapper */ .content-wrapper { - flex: 1 0 auto; - display: flex; - flex-direction: column; - overflow: hidden; - padding: 10px; - max-width: 100%; - margin: 0; - position: relative; - z-index: 1; - min-height: 0; + flex: 1 0 auto; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 10px; + max-width: 100%; + margin: 0; + position: relative; + z-index: 1; + min-height: 0; } .content-grid { - display: grid; - grid-template-columns: 380px 1fr; - gap: 10px; - align-items: start; + display: grid; + grid-template-columns: 380px 1fr; + gap: 10px; + align-items: start; } /* Main Content Area */ .main-content-area { - flex: 1 0 auto; - min-width: 0; - overflow: visible; - display: flex; - flex-direction: column; - gap: 0.75rem; - position: relative; - z-index: 1; - padding: 1rem 0 4rem; + flex: 1 0 auto; + min-width: 0; + overflow: visible; + display: flex; + flex-direction: column; + gap: 0.75rem; + position: relative; + z-index: 1; + padding: 1rem 0 4rem; } .table-and-pagination { - display: flex; - flex-direction: column; - background: var(--bs-white); - border-radius: 8px; - margin-bottom: 1.5rem; - position: relative; + display: flex; + flex-direction: column; + background: var(--bs-white); + border-radius: 8px; + margin-bottom: 1.5rem; + position: relative; } /* Search Container */ .search-container { - margin: 1rem 0 0.25rem; - padding: 1rem; - background-color: var(--bs-white); - position: relative; - z-index: 2; - border-radius: 8px; + margin: 1rem 0 0.25rem; + padding: 1rem; + background-color: var(--bs-white); + position: relative; + z-index: 2; + border-radius: 8px; } .search-container .input-group { - background-color: var(--bs-white); + background-color: var(--bs-white); } .search-container .input-group .form-control { - background-color: var(--bs-white); - border: 1px solid var(--bs-gray-300); - border-radius: 0.25rem; - padding: 0.375rem 0.75rem; - font-size: 0.875rem; + background-color: var(--bs-white); + border: 1px solid var(--bs-gray-300); + border-radius: 0.25rem; + padding: 0.375rem 0.75rem; + font-size: 0.875rem; } .search-container .input-group .form-control:focus { - border-color: var(--google-blue); - box-shadow: 0 0 0 0.2rem var(--google-blue-shadow); + border-color: var(--google-blue); + box-shadow: 0 0 0 0.2rem var(--google-blue-shadow); } /* Table Container */ .table-container { - flex: 1; - min-height: 0; - overflow: auto; - position: relative; - margin-bottom: 0; - max-height: calc(100vh - 250px); - background: var(--bs-white); - padding: 1rem; - z-index: 1; - border-radius: 8px 8px 0 0; - height: auto; + flex: 1; + min-height: 0; + overflow: auto; + position: relative; + margin-bottom: 0; + max-height: calc(100vh - 250px); + background: var(--bs-white); + padding: 1rem; + z-index: 1; + border-radius: 8px 8px 0 0; + height: auto; } .table-responsive { - height: 100%; - overflow: auto; - margin: 0; - overflow-x: auto; - max-width: 100%; + height: 100%; + overflow: auto; + margin: 0; + overflow-x: auto; + max-width: 100%; } .table-responsive table { - margin-bottom: 0; - background: var(--bs-white); - width: 100%; - border-collapse: separate; - border-spacing: 0; + margin-bottom: 0; + background: var(--bs-white); + width: 100%; + border-collapse: separate; + border-spacing: 0; } .table-responsive .table { - background-color: var(--bs-white); - color: var(--bs-gray-800); + background-color: var(--bs-white); + color: var(--bs-gray-800); } .table-responsive .table thead { - position: sticky; - top: 0; - z-index: 3; - background: var(--bs-white); + position: sticky; + top: 0; + z-index: 3; + background: var(--bs-white); } .table-responsive .table thead::after { - content: ""; - position: absolute; - left: 0; - right: 0; - bottom: 0; - border-bottom: 2px solid var(--bs-gray-400); - box-shadow: 0 1px 0 var(--shadow-black-light); + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0; + border-bottom: 2px solid var(--bs-gray-400); + box-shadow: 0 1px 0 var(--shadow-black-light); } .table-responsive .table thead th { - background: var(--bs-white); - color: var(--bs-gray-700); - text-align: left; - padding: 12px; - border-top: none; - border-bottom: none; - border-right: none; - white-space: nowrap; - position: sticky; - top: 0; - z-index: 4; + background: var(--bs-white); + color: var(--bs-gray-700); + text-align: left; + padding: 12px; + border-top: none; + border-bottom: none; + border-right: none; + white-space: nowrap; + position: sticky; + top: 0; + z-index: 4; } .table-responsive .table tbody tr { - background-color: var(--bs-white); + background-color: var(--bs-white); } .table-responsive .table tbody tr:nth-child(even) { - background-color: var(--bs-white); + background-color: var(--bs-white); } .table-responsive .table tbody tr:hover { - background-color: var(--bs-gray-100); + background-color: var(--bs-gray-100); } .table-responsive .table tbody tr td { - padding: 12px; - border: none; - color: var(--bs-gray-800); - vertical-align: middle; - background-color: inherit; + padding: 12px; + border: none; + color: var(--bs-gray-800); + vertical-align: middle; + background-color: inherit; } .table-responsive .table tbody tr td a { - color: var(--lfs-blue); - font-weight: 400; - text-decoration: underline; + color: var(--lfs-blue); + font-weight: 400; + text-decoration: underline; } .table-responsive .table tbody tr td a:hover { - color: var(--bs-primary-dark); + color: var(--bs-primary-dark); } /* Filters Panel Styles */ .filters-panel { - background: var(--bs-white); - padding: 15px; - border-radius: 8px; - height: calc(100vh - 380px); - overflow-y: auto; - border: none; - margin-top: 50px; + background: var(--bs-white); + padding: 15px; + border-radius: 8px; + height: calc(100vh - 380px); + overflow-y: auto; + border: none; + margin-top: 50px; } .filters-panel .filters-section { - margin-bottom: 20px; - background: var(--bs-white); - padding: 0 1.5rem; - box-sizing: border-box; + margin-bottom: 20px; + background: var(--bs-white); + padding: 0 1.5rem; + box-sizing: border-box; } .filters-panel .filters-section .filter-header { - font-size: 14px; - font-weight: 600; - margin-bottom: 10px; - cursor: pointer; - display: flex; - justify-content: space-between; - align-items: center; - color: var(--bs-gray-700); - background: var(--bs-white); - padding: 0.5rem; - border-radius: 4px; - transition: background-color 0.2s ease; + font-size: 14px; + font-weight: 600; + margin-bottom: 10px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + color: var(--bs-gray-700); + background: var(--bs-white); + padding: 0.5rem; + border-radius: 4px; + transition: background-color 0.2s ease; } .filters-panel .filters-section .filter-header:hover { - background-color: var(--bs-gray-100); + background-color: var(--bs-gray-100); } .filters-panel .filters-section .filter-header .bi-chevron-down { - transition: transform 0.2s ease; + transition: transform 0.2s ease; } .filters-panel - .filters-section - .filter-header[aria-expanded="false"] - .bi-chevron-down { - transform: rotate(-90deg); + .filters-section + .filter-header[aria-expanded="false"] + .bi-chevron-down { + transform: rotate(-90deg); } .filters-panel .filters-section .filter-content { - padding: 0.75rem 0.5rem; - background: var(--bs-white); + padding: 0.75rem 0.5rem; + background: var(--bs-white); } .filters-panel .filters-section .form-check { - margin-bottom: 0.5rem; - padding-left: 1.75rem; + margin-bottom: 0.5rem; + padding-left: 1.75rem; } .filters-panel .filters-section .form-check .form-check-input { - background-color: var(--bs-white); - border: 1px solid var(--bs-gray-300); - margin-left: -1.75rem; + background-color: var(--bs-white); + border: 1px solid var(--bs-gray-300); + margin-left: -1.75rem; } .filters-panel .filters-section .form-check .form-check-input:checked { - background-color: var(--google-blue); - border-color: var(--google-blue); + background-color: var(--google-blue); + border-color: var(--google-blue); } .filters-panel .filters-section .form-group { - margin-bottom: 0.75rem; + margin-bottom: 0.75rem; } .filters-panel .filters-section .form-group .form-label { - margin-bottom: 0.25rem; - color: var(--bs-gray-600); - font-size: 0.75rem; - white-space: nowrap; + margin-bottom: 0.25rem; + color: var(--bs-gray-600); + font-size: 0.75rem; + white-space: nowrap; } .filters-panel .filters-section .form-group .form-control { - background-color: var(--bs-white); - border: 1px solid var(--bs-gray-300); - font-size: 0.875rem; - padding: 0.375rem 0.75rem; - border-radius: 0.25rem; + background-color: var(--bs-white); + border: 1px solid var(--bs-gray-300); + font-size: 0.875rem; + padding: 0.375rem 0.75rem; + border-radius: 0.25rem; } .filters-panel .filters-section .form-group .form-control:focus { - border-color: var(--google-blue); - box-shadow: 0 0 0 0.2rem var(--google-blue-shadow); + border-color: var(--google-blue); + box-shadow: 0 0 0 0.2rem var(--google-blue-shadow); } .filters-panel .filters-section .form-select { - background-color: var(--bs-white); - border: 1px solid var(--bs-gray-300); - font-size: 0.875rem; - padding: 0.375rem 0.75rem; - border-radius: 0.25rem; + background-color: var(--bs-white); + border: 1px solid var(--bs-gray-300); + font-size: 0.875rem; + padding: 0.375rem 0.75rem; + border-radius: 0.25rem; } .filters-panel .filters-section .form-select:focus { - border-color: var(--google-blue); - box-shadow: 0 0 0 0.2rem var(--google-blue-shadow); + border-color: var(--google-blue); + box-shadow: 0 0 0 0.2rem var(--google-blue-shadow); } /* Sort Styles */ .sort-link { - display: inline-flex; - align-items: center; - gap: 0.25rem; - text-decoration: none; - color: inherit; - cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.25rem; + text-decoration: none; + color: inherit; + cursor: pointer; } .sort-link .sort-icons { - display: inline-flex; - line-height: 1; - position: relative; - width: 14px; - height: 14px; - margin-top: 0.1rem; + display: inline-flex; + line-height: 1; + position: relative; + width: 14px; + height: 14px; + margin-top: 0.1rem; } .sort-link .sort-icons i { - font-size: 16px; - color: var(--bs-black); - position: absolute; - transition: color 0.2s ease; + font-size: 16px; + color: var(--bs-black); + position: absolute; + transition: color 0.2s ease; } .sort-link .sort-icons i.bi-caret-up-fill, .sort-link .sort-icons i.bi-caret-down-fill { - color: var(--bs-black); + color: var(--bs-black); } .sort-link:hover { - color: var(--bs-primary); - text-decoration: none; + color: var(--bs-primary); + text-decoration: none; } /* Pagination Container */ .pagination-container { - padding: 1rem; - background: var(--bs-white); - position: relative; - z-index: 999; - width: 100%; - display: flex; - justify-content: center; - align-items: center; - margin-top: 0; - border-radius: 0 0 8px 8px; - border-top: 1px solid var(--bs-gray-100); + padding: 1rem; + background: var(--bs-white); + position: relative; + z-index: 999; + width: 100%; + display: flex; + justify-content: center; + align-items: center; + margin-top: 0; + border-radius: 0 0 8px 8px; + border-top: 1px solid var(--bs-gray-100); } .pagination-container .pagination { - margin: 0; - display: flex; - gap: 0.5rem; + margin: 0; + display: flex; + gap: 0.5rem; } .pagination-container .pagination .page-item { - margin: 0; + margin: 0; } .pagination-container .pagination .page-item .page-link { - width: 2.5rem; - height: 2.5rem; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - border: 1px solid var(--bs-gray-300); - color: var(--bs-gray-600); - background-color: var(--bs-white); - transition: all 0.2s ease-in-out; + width: 2.5rem; + height: 2.5rem; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + border: 1px solid var(--bs-gray-300); + color: var(--bs-gray-600); + background-color: var(--bs-white); + transition: all 0.2s ease-in-out; } .pagination-container .pagination .page-item .page-link:hover { - background-color: var(--bs-gray-100); - border-color: var(--bs-gray-300); - color: var(--bs-gray-700); + background-color: var(--bs-gray-100); + border-color: var(--bs-gray-300); + color: var(--bs-gray-700); } .pagination-container .pagination .page-item.active .page-link { - background-color: var(--bs-primary); - border-color: var(--bs-primary); - color: var(--bs-white); + background-color: var(--bs-primary); + border-color: var(--bs-primary); + color: var(--bs-white); } /* Empty State Styles */ .alert-debug { - background-color: var(--bs-gray-100); - border-color: var(--bs-gray-300); - color: var(--bs-gray-600); + background-color: var(--bs-gray-100); + border-color: var(--bs-gray-300); + color: var(--bs-gray-600); } .italic-text { - font-style: italic; + font-style: italic; } /* Sort Arrow Styles */ .sort-link { - display: inline-flex; - margin-left: 4px; - text-decoration: none; - color: inherit; + display: inline-flex; + margin-left: 4px; + text-decoration: none; + color: inherit; } .sort-link .sort-icons { - display: inline-flex; - flex-direction: column; - margin-top: 10px; - line-height: 0.5; - position: relative; - width: 14px; - height: 30px; + display: inline-flex; + flex-direction: column; + margin-top: 10px; + line-height: 0.5; + position: relative; + width: 14px; + height: 30px; } .sort-link .sort-icons i { - font-size: 14px; - color: var(--light-gray-text); - position: absolute; + font-size: 14px; + color: var(--light-gray-text); + position: absolute; } .sort-link .sort-icons i.active { - color: var(--bs-black); + color: var(--bs-black); } .sort-link:hover { - color: inherit; - text-decoration: none; + color: inherit; + text-decoration: none; } html, body { - height: 100%; - margin: 0; - padding: 0; + height: 100%; + margin: 0; + padding: 0; } .list-title { - text-align: left; - padding: 0; - font-size: 1.5rem; - margin-bottom: 0px; + text-align: left; + padding: 0; + font-size: 1.5rem; + margin-bottom: 0px; } .w-5 { - width: 5%; + width: 5%; } .w-10 { - width: 10%; + width: 10%; } .w-15 { - width: 15%; + width: 15%; } .w-20 { - width: 20%; + width: 20%; } .w-30 { - width: 30%; + width: 30%; } .w-35 { - width: 35%; + width: 35%; } .step-number { - display: inline-block; - width: 24px; - height: 24px; - line-height: 24px; - text-align: center; - border-radius: 50%; - margin-right: 8px; + display: inline-block; + width: 24px; + height: 24px; + line-height: 24px; + text-align: center; + border-radius: 50%; + margin-right: 8px; } /* Step number colors for different button states */ .btn-primary .step-number { - background-color: var(--lfs-blue); - color: var(--bs-white); + background-color: var(--lfs-blue); + color: var(--bs-white); } .btn-outline-primary .step-number { - background-color: transparent; - color: var(--lfs-blue); - border: 1px solid var(--lfs-blue); + background-color: transparent; + color: var(--lfs-blue); + border: 1px solid var(--lfs-blue); } /* Captures Table Styles */ #captures-table tbody tr.table-warning { - --bs-table-bg: var(--bs-warning-bg-subtle); - background-color: var(--bs-warning-bg-subtle); + --bs-table-bg: var(--bs-warning-bg-subtle); + background-color: var(--bs-warning-bg-subtle); } #captures-table tbody tr.capture-row { - cursor: pointer; + cursor: pointer; } /* Selected Captures and Review Tables */ #selected-captures-pane .card-header, #step3 .card-header, #step4 .card .card-header { - border-radius: 0.5rem 0.5rem 0 0; + border-radius: 0.5rem 0.5rem 0 0; } #selected-captures-pane .card-body, #step3 .card-body, #step4 .card .card-body { - border-radius: 0 0 0.5rem 0.5rem; - overflow: hidden; + border-radius: 0 0 0.5rem 0.5rem; + overflow: hidden; } #selected-captures-pane .table, #step3 .table, #step4 .card .table { - margin-bottom: 0; + margin-bottom: 0; } /* Group Captures Card Styles */ #group-captures-card { - height: calc(80vh - 4rem); - display: flex; - flex-direction: column; - margin-bottom: 1rem; - overflow-y: hidden; + height: calc(80vh - 4rem); + display: flex; + flex-direction: column; + margin-bottom: 1rem; + overflow-y: hidden; } #group-captures-card .card-header { - flex-shrink: 0; + flex-shrink: 0; } #group-captures-card .card-body { - flex: 1; - width: 100%; - overflow-y: auto; - max-height: 100%; - padding: 0; - /* Remove default padding */ + flex: 1; + width: 100%; + overflow-y: auto; + max-height: 100%; + padding: 0; + /* Remove default padding */ } #group-captures-card .card-footer { - flex-shrink: 0; - background-color: var(--bs-gray-100); - border-top: 1px solid var(--bs-gray-300); - padding: 0.5rem; + flex-shrink: 0; + background-color: var(--bs-gray-100); + border-top: 1px solid var(--bs-gray-300); + padding: 0.5rem; } /* Step tabs sticky header */ #stepTabs-container { - position: sticky; - top: 0; - z-index: 10; - background: var(--bs-white); - padding: 1.25rem; - border-bottom: 1px solid #dee2e6; - margin-bottom: 0; + position: sticky; + top: 0; + z-index: 10; + background: var(--bs-white); + padding: 1.25rem; + border-bottom: 1px solid #dee2e6; + margin-bottom: 0; } /* Content below sticky header */ #stepTabsContent { - padding: 1.25rem; + padding: 1.25rem; } #captures-search-form { - padding: 1.25rem; + padding: 1.25rem; } /* Review page specific styles */ #step4 .card .card-body:not(.p-0) { - padding: 1.25rem; + padding: 1.25rem; } .disable-events { - pointer-events: none; - cursor: not-allowed; + pointer-events: none; + cursor: not-allowed; } .display-block { - display: block; + display: block; } .display-inline-block { - display: inline-block; + display: inline-block; } .display-table-row { - display: table-row; + display: table-row; } .display-none { - display: none; + display: none; } .active-tab { - opacity: 1; + opacity: 1; } .inactive-tab { - opacity: 0.65; + opacity: 0.65; } .btn-group-disabled { - pointer-events: none; + pointer-events: none; } /* File Tree Table Container */ .file-tree-container { - height: 50vh; - overflow-y: auto; - margin: 1rem 0; - border: 1px solid #dee2e6; - border-radius: 0.375rem; + height: 50vh; + overflow-y: auto; + margin: 1rem 0; + border: 1px solid #dee2e6; + border-radius: 0.375rem; } #file-tree-table { - margin-bottom: 0; + margin-bottom: 0; } #file-tree-table thead.sticky-top { - position: sticky; - top: 0; - z-index: 2; - background-color: var(--bs-white); + position: sticky; + top: 0; + z-index: 2; + background-color: var(--bs-white); } #file-tree-table tbody tr:hover { - cursor: pointer; + cursor: pointer; } .action-buttons { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; + display: flex; + gap: 0.5rem; + flex-wrap: wrap; } .action-buttons .btn { - font-size: 0.875rem; - padding: 0.375rem 0.75rem; + font-size: 0.875rem; + padding: 0.375rem 0.75rem; } .download-dataset-btn:disabled { - opacity: 0.6; - cursor: not-allowed; + opacity: 0.6; + cursor: not-allowed; } /* User search input container and dropdown styles */ .user-search-input-container { - position: relative; + position: relative; } .user-search-dropdown { - position: absolute; - top: 100%; - left: 0; - right: 0; - z-index: 9999; - background: var(--bs-white); - border: 1px solid var(--bs-gray-300); - border-radius: 0.375rem; - box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); - max-height: 200px; - overflow-y: auto; + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 9999; + background: var(--bs-white); + border: 1px solid var(--bs-gray-300); + border-radius: 0.375rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + max-height: 200px; + overflow-y: auto; } .user-search-dropdown .list-group-item { - border: none; - border-bottom: 1px solid var(--bs-gray-300); - cursor: pointer; - padding-top: 0.75rem; - padding-bottom: 0.75rem; - padding-left: 1.25rem; - padding-right: 1.25rem; + border: none; + border-bottom: 1px solid var(--bs-gray-300); + cursor: pointer; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + padding-left: 1.25rem; + padding-right: 1.25rem; } .user-search-dropdown .list-group-item:last-child { - border-bottom: none; + border-bottom: none; } .user-search-dropdown .list-group-item:hover { - background-color: var(--bs-gray-100); + background-color: var(--bs-gray-100); } .user-search-dropdown .list-group-item:active { - background-color: var(--bs-gray-200); + background-color: var(--bs-gray-200); } .user-search-dropdown .list-group-item.selected { - background-color: var(--bs-primary); - color: var(--bs-white); + background-color: var(--bs-primary); + color: var(--bs-white); } .user-search-dropdown .list-group-item.selected .user-name, .user-search-dropdown .list-group-item.selected .user-email { - color: var(--bs-white); + color: var(--bs-white); } .user-search-item { - display: flex; - flex-direction: column; - gap: 0.25rem; + display: flex; + flex-direction: column; + gap: 0.25rem; } .user-search-item .user-name { - font-weight: 500; - color: var(--bs-gray-800); + font-weight: 500; + color: var(--bs-gray-800); } .user-search-item .user-email { - font-size: 0.875rem; - color: var(--bs-gray-600); + font-size: 0.875rem; + color: var(--bs-gray-600); } .no-results { - padding: 0.75rem 1rem; - color: var(--bs-gray-600); - font-style: italic; - text-align: center; + padding: 0.75rem 1rem; + color: var(--bs-gray-600); + font-style: italic; + text-align: center; } /* Highlight matching text */ .user-search-dropdown mark { - background-color: var(--bs-warning-bg); - color: var(--bs-warning-dark); - padding: 0.1em 0.2em; - border-radius: 0.2em; + background-color: var(--bs-warning-bg); + color: var(--bs-warning-dark); + padding: 0.1em 0.2em; + border-radius: 0.2em; } .user-search-dropdown .list-group-item.selected mark { - background-color: var(--bs-warning); - color: var(--bs-black); + background-color: var(--bs-warning); + color: var(--bs-black); } /* Share Group Manager Specific Styles */ .selected-users-chips { - gap: 0.5rem; - margin-bottom: 0.25rem; + gap: 0.5rem; + margin-bottom: 0.25rem; } /* New styles for user chips with permission levels */ .selected-users-permissions-section .selected-users-chips { - gap: 0.75rem; - margin-bottom: 0.5rem; + gap: 0.75rem; + margin-bottom: 0.5rem; } .selected-users-permissions-section .user-chip { - display: flex; - align-items: center; - justify-content: space-between; - background: var(--bs-gray-100); - border: 1px solid var(--bs-gray-300); - border-radius: 0.5rem; - padding: 0.75rem 1rem; - margin-right: 0; - font-size: 1rem; - width: 100%; - transition: all 0.2s ease; - gap: 1rem; + display: flex; + align-items: center; + justify-content: space-between; + background: var(--bs-gray-100); + border: 1px solid var(--bs-gray-300); + border-radius: 0.5rem; + padding: 0.75rem 1rem; + margin-right: 0; + font-size: 1rem; + width: 100%; + transition: all 0.2s ease; + gap: 1rem; } .selected-users-permissions-section .user-chip:hover { - background: var(--bs-gray-200); - border-color: var(--bs-gray-500); + background: var(--bs-gray-200); + border-color: var(--bs-gray-500); } .selected-users-permissions-section .user-chip .user-info { - display: flex; - align-items: center; - gap: 0.5rem; - flex: 1; - min-width: 0; + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; + min-width: 0; } .selected-users-permissions-section .user-chip .user-name { - font-weight: 500; - color: var(--bs-gray-700); - word-break: break-word; + font-weight: 500; + color: var(--bs-gray-700); + word-break: break-word; } .selected-users-permissions-section .user-chip .user-email { - font-size: 0.875rem; - color: var(--bs-gray-600); - word-break: break-word; + font-size: 0.875rem; + color: var(--bs-gray-600); + word-break: break-word; } .selected-users-permissions-section .user-chip .permission-select { - min-width: 120px; - max-width: 140px; - font-size: 0.875rem; - flex-shrink: 0; + min-width: 120px; + max-width: 140px; + font-size: 0.875rem; + flex-shrink: 0; } .selected-users-permissions-section .user-chip .remove-chip { - cursor: pointer; - color: var(--bs-gray-600); - font-weight: bold; - font-size: 1.2rem; - line-height: 1; - padding: 0.25rem; - border-radius: 0.25rem; - transition: all 0.2s ease; - flex-shrink: 0; + cursor: pointer; + color: var(--bs-gray-600); + font-weight: bold; + font-size: 1.2rem; + line-height: 1; + padding: 0.25rem; + border-radius: 0.25rem; + transition: all 0.2s ease; + flex-shrink: 0; } .selected-users-permissions-section .user-chip .remove-chip:hover { - color: var(--bs-danger); - background-color: var(--bs-danger-light); + color: var(--bs-danger); + background-color: var(--bs-danger-light); } /* Original user chip styles (for other contexts) */ .user-chip { - display: inline-flex; - align-items: center; - background: var(--bs-gray-200); - border-radius: 16px; - padding: 0.25em 0.75em; - margin-right: 0.25em; - font-size: 0.95em; + display: inline-flex; + align-items: center; + background: var(--bs-gray-200); + border-radius: 16px; + padding: 0.25em 0.75em; + margin-right: 0.25em; + font-size: 0.95em; } .user-chip .remove-chip { - margin-left: 0.5em; - cursor: pointer; - color: var(--medium-gray-text); - font-weight: bold; - font-size: 1.1em; - line-height: 1; + margin-left: 0.5em; + cursor: pointer; + color: var(--medium-gray-text); + font-weight: bold; + font-size: 1.1em; + line-height: 1; } .user-chip .remove-chip:hover { - color: var(--red-text); + color: var(--red-text); } /* Share groups list: action dropdowns are not clipped by table scroll */ .share-groups-table-responsive.table-responsive { - overflow: visible; + overflow: visible; } /* Remove member button sizing for share groups */ .remove-member-btn.btn-danger, .remove-member-btn.btn-outline-danger { - padding: 0.375rem 0.75rem; - font-size: 0.875rem; - line-height: 1.5; - border-radius: 0.375rem; - border-width: 1px; - min-width: 80px; - height: auto; + padding: 0.375rem 0.75rem; + font-size: 0.875rem; + line-height: 1.5; + border-radius: 0.375rem; + border-width: 1px; + min-width: 80px; + height: auto; } /* Delete modal z-index for share groups */ #deleteGroupModal { - z-index: 2055; + z-index: 2055; } #deleteGroupModal .modal-dialog { - z-index: 2056; + z-index: 2056; } /* Group member popup styles */ .group-members-popup { - position: absolute; - top: 100%; - left: 0; - background: var(--bs-white); - border: 1px solid var(--bs-gray-300); - border-radius: 0.375rem; - padding: 0; - box-shadow: 0 0.5rem 1rem var(--shadow-black-medium); - z-index: 1000; - min-width: 250px; - max-width: 350px; + position: absolute; + top: 100%; + left: 0; + background: var(--bs-white); + border: 1px solid var(--bs-gray-300); + border-radius: 0.375rem; + padding: 0; + box-shadow: 0 0.5rem 1rem var(--shadow-black-medium); + z-index: 1000; + min-width: 250px; + max-width: 350px; } /* Popover arrow/pointer */ .group-members-popup::before { - content: ""; - position: absolute; - top: -8px; - left: 20px; - width: 0; - height: 0; - border-left: 8px solid transparent; - border-right: 8px solid transparent; - border-bottom: 8px solid var(--bs-white); + content: ""; + position: absolute; + top: -8px; + left: 20px; + width: 0; + height: 0; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-bottom: 8px solid var(--bs-white); } .group-members-popup::after { - content: ""; - position: absolute; - top: -9px; - left: 20px; - width: 0; - height: 0; - border-left: 8px solid transparent; - border-right: 8px solid transparent; - border-bottom: 8px solid var(--bs-gray-300); + content: ""; + position: absolute; + top: -9px; + left: 20px; + width: 0; + height: 0; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-bottom: 8px solid var(--bs-gray-300); } /* Popover content */ .group-members-popup .popover-header { - padding: 0.5rem 0.75rem; - margin-bottom: 0; - font-size: 0.875rem; - font-weight: 600; - color: var(--bs-gray-700); - background-color: var(--bs-gray-100); - border-bottom: 1px solid var(--bs-gray-300); - border-top-left-radius: 0.375rem; - border-top-right-radius: 0.375rem; + padding: 0.5rem 0.75rem; + margin-bottom: 0; + font-size: 0.875rem; + font-weight: 600; + color: var(--bs-gray-700); + background-color: var(--bs-gray-100); + border-bottom: 1px solid var(--bs-gray-300); + border-top-left-radius: 0.375rem; + border-top-right-radius: 0.375rem; } .group-members-popup .popover-body { - padding: 0.75rem; + padding: 0.75rem; } .group-members-popup small { - line-height: 1.4; + line-height: 1.4; } /* Ensure table rows have relative positioning for popup positioning */ .table tbody tr { - position: relative; + position: relative; } /* Group chip styles */ .user-chip .bi-people-fill { - color: var(--cyan); + color: var(--cyan); } .user-chip .bi-person-fill { - color: var(--bs-primary); + color: var(--bs-primary); } .table-container { - min-height: 400px; + min-height: 400px; } .modal { - z-index: 2050; + z-index: 2050; } .modal-backdrop { - z-index: 2040; + z-index: 2040; } .modal-divider { - margin-left: -1rem; - margin-right: -1rem; - margin-top: 2rem; - margin-bottom: 1rem; - border-top: 1px solid var(--medium-gray-border); + margin-left: -1rem; + margin-right: -1rem; + margin-top: 2rem; + margin-bottom: 1rem; + border-top: 1px solid var(--medium-gray-border); } .selected-users-chips { - gap: 0.5rem; - margin-bottom: 0.25rem; + gap: 0.5rem; + margin-bottom: 0.25rem; } .user-chip { - display: inline-flex; - align-items: center; - background: var(--bs-gray-200); - border-radius: 16px; - padding: 0.25em 0.75em; - margin-right: 0.25em; - font-size: 0.95em; + display: inline-flex; + align-items: center; + background: var(--bs-gray-200); + border-radius: 16px; + padding: 0.25em 0.75em; + margin-right: 0.25em; + font-size: 0.95em; } .user-chip .remove-chip { - margin-left: 0.5em; - cursor: pointer; - color: var(--medium-gray-text); - font-weight: bold; - font-size: 1.1em; - line-height: 1; + margin-left: 0.5em; + cursor: pointer; + color: var(--medium-gray-text); + font-weight: bold; + font-size: 1.1em; + line-height: 1; } .user-chip .remove-chip:hover { - color: var(--red-text); + color: var(--red-text); } .notify-message-anim { - overflow: hidden; - transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s - cubic-bezier(0.4, 0, 0.2, 1); - max-height: 0; - opacity: 0; - pointer-events: none; + overflow: hidden; + transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s + cubic-bezier(0.4, 0, 0.2, 1); + max-height: 0; + opacity: 0; + pointer-events: none; } .notify-message-anim.show { - max-height: 200px; - /* adjust as needed */ - opacity: 1; - pointer-events: auto; + max-height: 200px; + /* adjust as needed */ + opacity: 1; + pointer-events: auto; } /* Dataset Name Link Styles */ .dataset-name-link { - color: var(--bs-primary); - text-decoration: none; - transition: color 0.2s ease; + color: var(--bs-primary); + text-decoration: none; + transition: color 0.2s ease; } .dataset-name-link:hover { - color: var(--bs-primary-hover); - text-decoration: underline; + color: var(--bs-primary-hover); + text-decoration: underline; } .dataset-name-link.text-muted { - color: var(--bs-gray-600); + color: var(--bs-gray-600); } .dataset-name-link.text-muted:hover { - color: var(--bs-gray-700); + color: var(--bs-gray-700); } .clickable-row { - cursor: pointer; + cursor: pointer; } .clickable-row:hover { - background-color: var(--shadow-black-very-light); + background-color: var(--shadow-black-very-light); } /* Copy UUID Button Styles */ .copy-uuid-btn { - transition: all 0.2s ease; - background: none; - border: none; - border-radius: 0.25rem; - padding: 0 0.25rem; - color: var(--bs-gray-600); - font-size: inherit; - cursor: pointer; + transition: all 0.2s ease; + background: none; + border: none; + border-radius: 0.25rem; + padding: 0 0.25rem; + color: var(--bs-gray-600); + font-size: inherit; + cursor: pointer; } .copy-uuid-btn:hover { - background: none; - border: none; - color: var(--bs-gray-700); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + background: none; + border: none; + color: var(--bs-gray-700); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .copy-uuid-btn:active { - transform: none; - box-shadow: none; + transform: none; + box-shadow: none; } .copy-uuid-btn.btn-success { - background: none; - border: none; - color: var(--bs-success); + background: none; + border: none; + color: var(--bs-success); } .copy-uuid-btn.btn-success:hover { - background: none; - border: none; - color: var(--bs-success-dark); + background: none; + border: none; + color: var(--bs-success-dark); } .copy-uuid-btn.btn-danger { - background: none; - border: none; - color: var(--bs-danger); + background: none; + border: none; + color: var(--bs-danger); } .copy-uuid-btn.btn-danger:hover { - background: none; - border: none; - color: var(--bs-danger-dark); + background: none; + border: none; + color: var(--bs-danger-dark); } .code-example pre { - font-size: 0.875rem; - line-height: 1.4; - margin: 0; - white-space: pre-wrap; - word-wrap: break-word; - background: var(--bs-gray-100); - border: 1px solid var(--bs-gray-300); + font-size: 0.875rem; + line-height: 1.4; + margin: 0; + white-space: pre-wrap; + word-wrap: break-word; + background: var(--bs-gray-100); + border: 1px solid var(--bs-gray-300); } .code-example pre code { - background: transparent; - color: var(--bs-gray-800); - text-shadow: none; - font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace; + background: transparent; + color: var(--bs-gray-800); + text-shadow: none; + font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace; } .copy-btn { - font-size: 0.75rem; - padding: 0.25rem 0.5rem; + font-size: 0.75rem; + padding: 0.25rem 0.5rem; } .copy-btn:hover { - background-color: var(--lfs-blue); - border-color: var(--lfs-blue); - color: var(--bs-white); + background-color: var(--lfs-blue); + border-color: var(--lfs-blue); + color: var(--bs-white); } .copy-btn.copied { - background-color: var(--bs-success); - border-color: var(--bs-success); - color: var(--bs-white); + background-color: var(--bs-success); + border-color: var(--bs-success); + color: var(--bs-white); } /* Prism.js light theme custom styles */ .token.comment { - color: var(--bs-gray-600); - font-style: italic; + color: var(--bs-gray-600); + font-style: italic; } .token.string { - color: var(--bs-success); + color: var(--bs-success); } .token.keyword { - color: var(--lfs-blue); + color: var(--lfs-blue); } .token.function { - color: var(--bs-info); + color: var(--bs-info); } .token.number { - color: var(--pink-magenta); + color: var(--pink-magenta); } .token.operator { - color: var(--bs-gray-600); + color: var(--bs-gray-600); } .token.punctuation { - color: var(--bs-gray-600); + color: var(--bs-gray-600); } .token.class-name { - color: var(--lfs-blue); + color: var(--lfs-blue); } .token.builtin { - color: var(--orange); + color: var(--orange); } .token.boolean { - color: var(--pink-magenta); + color: var(--pink-magenta); } /* Copy UUID Button Styles */ .copy-uuid-btn { - transition: all 0.2s ease; - background: none; - border: none; - border-radius: 0.25rem; - padding: 0 0.25rem; - color: var(--bs-gray-600); - font-size: inherit; - cursor: pointer; + transition: all 0.2s ease; + background: none; + border: none; + border-radius: 0.25rem; + padding: 0 0.25rem; + color: var(--bs-gray-600); + font-size: inherit; + cursor: pointer; } .copy-uuid-btn:hover { - background: none; - border: none; - color: var(--bs-gray-700); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + background: none; + border: none; + color: var(--bs-gray-700); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .copy-uuid-btn:active { - transform: none; - box-shadow: none; + transform: none; + box-shadow: none; } .copy-uuid-btn.btn-success { - background: none; - border: none; - color: var(--bs-success); + background: none; + border: none; + color: var(--bs-success); } .copy-uuid-btn.btn-success:hover { - background: none; - border: none; - color: var(--bs-success-dark); + background: none; + border: none; + color: var(--bs-success-dark); } .copy-uuid-btn.btn-danger { - background: none; - border: none; - color: var(--bs-danger); + background: none; + border: none; + color: var(--bs-danger); } .copy-uuid-btn.btn-danger:hover { - background: none; - border: none; - color: var(--bs-danger-dark); + background: none; + border: none; + color: var(--bs-danger-dark); } .keyword-chips-wrapper { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.25rem; - padding: 0.375rem 0.75rem; - min-height: 38px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; + padding: 0.375rem 0.75rem; + min-height: 38px; } .keyword-chips-wrapper:focus-within { - border-color: #86b7fe; - outline: 0; - box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); + border-color: #86b7fe; + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); } .keyword-input { - border: none; - outline: none; - flex: 1; - min-width: 120px; + border: none; + outline: none; + flex: 1; + min-width: 120px; } .keyword-chip { - display: inline-flex; - align-items: center; - gap: 0.25rem; - padding: 0.25rem 0.5rem; - font-size: 0.875rem; + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + font-size: 0.875rem; } .keyword-chip .btn-close { - font-size: 0.65em; - padding: 0.125rem; + font-size: 0.65em; + padding: 0.125rem; } diff --git a/gateway/sds_gateway/static/css/custom.css b/gateway/sds_gateway/static/css/custom.css index 8dd70510a..2e27f72fd 100644 --- a/gateway/sds_gateway/static/css/custom.css +++ b/gateway/sds_gateway/static/css/custom.css @@ -1,186 +1,186 @@ :root { - --primary-color: #002147; - --secondary-color: #0056b3; - --text-color: #333; - --light-gray: #f5f5f5; - --white: #fff; + --primary-color: #002147; + --secondary-color: #0056b3; + --text-color: #333; + --light-gray: #f5f5f5; + --white: #fff; } * { - margin: 0; - padding: 0; - box-sizing: border-box; + margin: 0; + padding: 0; + box-sizing: border-box; } body { - font-family: "Inter", sans-serif; - line-height: 1.6; - color: var(--text-color); + font-family: "Inter", sans-serif; + line-height: 1.6; + color: var(--text-color); } .container { - max-width: 1200px; - margin: 0 auto; - padding: 0 20px; + max-width: 1200px; + margin: 0 auto; + padding: 0 20px; } /* Rainbow Bar */ .rainbow-bar { - height: 4px; - width: 100%; - background: linear-gradient( - to right, - #ff0000, - /* Red */ - #ff7f00, - /* Orange */ - #ffff00, - /* Yellow */ - #00ff00, - /* Green */ - #0000ff, - /* Blue */ - #4b0082, - /* Indigo */ - #8f00ff - /* Violet */ - ); - position: fixed; - top: 0; - left: 0; - z-index: 1001; + height: 4px; + width: 100%; + background: linear-gradient( + to right, + #ff0000, + /* Red */ + #ff7f00, + /* Orange */ + #ffff00, + /* Yellow */ + #00ff00, + /* Green */ + #0000ff, + /* Blue */ + #4b0082, + /* Indigo */ + #8f00ff + /* Violet */ + ); + position: fixed; + top: 0; + left: 0; + z-index: 1001; } /* Header Styles */ .site-header { - background: var(--white); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - position: fixed; - width: 100%; - top: 4px; - /* Changed from 0 to 4px to account for rainbow bar */ - z-index: 1000; + background: var(--white); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + position: fixed; + width: 100%; + top: 4px; + /* Changed from 0 to 4px to account for rainbow bar */ + z-index: 1000; } .header-content { - display: flex; - justify-content: space-between; - align-items: center; - padding: 1rem 0; + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 0; } .logo img { - height: 40px; + height: 40px; } .logo a { - display: flex; - align-items: center; - text-decoration: none; - color: var(--text-color); + display: flex; + align-items: center; + text-decoration: none; + color: var(--text-color); } .logo span { - font-size: 1.25rem; - font-weight: 500; - margin-left: 1rem; + font-size: 1.25rem; + font-weight: 500; + margin-left: 1rem; } .main-nav { - display: flex; - gap: 2rem; + display: flex; + gap: 2rem; } .main-nav ul { - list-style: none; - display: flex; - gap: 1.5rem; + list-style: none; + display: flex; + gap: 1.5rem; } .main-nav a { - text-decoration: none; - color: var(--text-color); - font-weight: 500; - transition: color 0.3s; + text-decoration: none; + color: var(--text-color); + font-weight: 500; + transition: color 0.3s; } .main-nav a:hover { - color: var(--secondary-color); + color: var(--secondary-color); } /* Hero Section */ .hero { - background-color: var(--primary-color); - color: var(--white); - padding: 8.25rem 0 4rem; - margin-bottom: 0; + background-color: var(--primary-color); + color: var(--white); + padding: 8.25rem 0 4rem; + margin-bottom: 0; } .hero h1 { - font-size: 2.5rem; - margin-bottom: 3rem; - text-align: center; + font-size: 2.5rem; + margin-bottom: 3rem; + text-align: center; } .hero-boxes { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 2rem; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 2rem; } .hero-box { - background: rgba(255, 255, 255, 0.1); - padding: 2rem; - border-radius: 8px; + background: rgba(255, 255, 255, 0.1); + padding: 2rem; + border-radius: 8px; } .hero-box h2 { - font-size: 1.5rem; - margin-bottom: 1rem; + font-size: 1.5rem; + margin-bottom: 1rem; } /* Add these styles for links in blue background sections */ .hero a { - color: var(--white); - text-decoration: underline; - transition: opacity 0.3s; + color: var(--white); + text-decoration: underline; + transition: opacity 0.3s; } .hero a:hover { - opacity: 0.8; + opacity: 0.8; } /* Update hero box styles to ensure proper contrast */ .hero-box a { - color: var(--white); - text-decoration: underline; - transition: opacity 0.3s; + color: var(--white); + text-decoration: underline; + transition: opacity 0.3s; } .hero-box a:hover { - opacity: 0.8; + opacity: 0.8; } /* Responsive Design */ @media (max-width: 768px) { - .hero-boxes { - grid-template-columns: 1fr; - } + .hero-boxes { + grid-template-columns: 1fr; + } - .main-nav { - display: none; - } + .main-nav { + display: none; + } } /* White bar section */ .white-bar { - background: var(--white); - padding: 2rem 0; - border-bottom: 1px solid #eee; + background: var(--white); + padding: 2rem 0; + border-bottom: 1px solid #eee; } /* Toast container styles */ .toast-container { - z-index: 1080; - min-width: 300px; + z-index: 1080; + min-width: 300px; } diff --git a/gateway/sds_gateway/static/css/file-list.css b/gateway/sds_gateway/static/css/file-list.css index 1caa7366e..da1283576 100644 --- a/gateway/sds_gateway/static/css/file-list.css +++ b/gateway/sds_gateway/static/css/file-list.css @@ -4,613 +4,613 @@ CSS Custom Properties (Variables) ================================ */ :root { - --primary-color: #005a9c; - --primary-hover: #004b80; - --light-gray: #f8f9fa; - --medium-gray: #e9ecef; - --text-muted: #6c757d; - --text-dark: #212529; - --text-secondary: #495057; - --border-radius: 8px; - --border-radius-lg: 16px; - --shadow-sm: 0 2px 6px rgba(0, 0, 0, 0.2); - --shadow-md: 0 2px 12px rgba(0, 0, 0, 0.06); - --shadow-lg: 0 10px 40px rgba(0, 0, 0, 0.15); - --transition: 0.2s ease; + --primary-color: #005a9c; + --primary-hover: #004b80; + --light-gray: #f8f9fa; + --medium-gray: #e9ecef; + --text-muted: #6c757d; + --text-dark: #212529; + --text-secondary: #495057; + --border-radius: 8px; + --border-radius-lg: 16px; + --shadow-sm: 0 2px 6px rgba(0, 0, 0, 0.2); + --shadow-md: 0 2px 12px rgba(0, 0, 0, 0.06); + --shadow-lg: 0 10px 40px rgba(0, 0, 0, 0.15); + --transition: 0.2s ease; } /* Hide native clear button in Chrome */ input[type="search"]::-webkit-search-cancel-button { - -webkit-appearance: none; - display: none; + -webkit-appearance: none; + display: none; } /* ================================ Layout & Container ================================ */ body { - min-height: 100vh; - display: flex; - flex-direction: column; - overflow-y: auto; + min-height: 100vh; + display: flex; + flex-direction: column; + overflow-y: auto; } .main-content { - flex: 1 0 auto; - display: flex; - flex-direction: column; + flex: 1 0 auto; + display: flex; + flex-direction: column; } .container-fluid { - flex: 1; + flex: 1; } /* ================================ Sidebar Filters ================================ */ .sidebar-filters { - background: #fff; - border-radius: var(--border-radius-lg); - box-shadow: var(--shadow-md); - padding: 2rem 1.5rem 1.5rem; - margin-bottom: 2rem; - position: sticky; - top: 1rem; + background: #fff; + border-radius: var(--border-radius-lg); + box-shadow: var(--shadow-md); + padding: 2rem 1.5rem 1.5rem; + margin-bottom: 2rem; + position: sticky; + top: 1rem; } #filtersAccordion { - margin-bottom: 1rem; + margin-bottom: 1rem; } /* Custom scrollbar styling */ .sidebar-filters::-webkit-scrollbar { - width: 6px; + width: 6px; } .sidebar-filters::-webkit-scrollbar-track { - background: #f1f1f1; - border-radius: 3px; + background: #f1f1f1; + border-radius: 3px; } .sidebar-filters::-webkit-scrollbar-thumb { - background: #c1c1c1; - border-radius: 3px; + background: #c1c1c1; + border-radius: 3px; } .sidebar-filters::-webkit-scrollbar-thumb:hover { - background: #a8a8a8; + background: #a8a8a8; } .sidebar-filters h2 { - font-size: 1.25rem; - font-weight: 600; - margin-bottom: 1.25rem; + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 1.25rem; } .sidebar-filters h4 { - font-size: 1.25rem; - font-weight: 600; - margin-bottom: 0.75rem; - color: var(--text-dark); + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 0.75rem; + color: var(--text-dark); } .sidebar-filters .accordion-item { - border: none; - margin-bottom: 0.5rem; - background: transparent; + border: none; + margin-bottom: 0.5rem; + background: transparent; } .sidebar-filters .accordion-button { - position: relative; - display: flex; - align-items: center; - width: 100%; - padding: 0.875rem 1.25rem; - background: var(--light-gray); - border-radius: var(--border-radius); - font-weight: 500; - font-size: 0.95rem; - box-shadow: none; - margin-bottom: 0.25rem; - border: none; - transition: all var(--transition); + position: relative; + display: flex; + align-items: center; + width: 100%; + padding: 0.875rem 1.25rem; + background: var(--light-gray); + border-radius: var(--border-radius); + font-weight: 500; + font-size: 0.95rem; + box-shadow: none; + margin-bottom: 0.25rem; + border: none; + transition: all var(--transition); } .sidebar-filters .accordion-button:not(.collapsed) { - background: var(--medium-gray); - color: var(--text-dark); + background: var(--medium-gray); + color: var(--text-dark); } .sidebar-filters .accordion-button::after { - flex-shrink: 0; - width: 1.25rem; - height: 1.25rem; - margin-left: auto; - content: ""; - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); - background-repeat: no-repeat; - background-size: 1.25rem; - transition: transform var(--transition); + flex-shrink: 0; + width: 1.25rem; + height: 1.25rem; + margin-left: auto; + content: ""; + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-size: 1.25rem; + transition: transform var(--transition); } .sidebar-filters .accordion-button:not(.collapsed)::after { - transform: rotate(-180deg); + transform: rotate(-180deg); } .accordion-collapse { - height: 0; - overflow: hidden; - transition: height 0.35s ease; + height: 0; + overflow: hidden; + transition: height 0.35s ease; } .accordion-collapse.show { - height: auto; + height: auto; } .sidebar-filters .accordion-body { - background: #fff; - border-radius: 0 0 var(--border-radius) var(--border-radius); - padding: 1rem; + background: #fff; + border-radius: 0 0 var(--border-radius) var(--border-radius); + padding: 1rem; } .sidebar-filters .btn { - font-weight: 500; - font-size: 0.95rem; - border-radius: var(--border-radius); - padding: 0.625rem 0; + font-weight: 500; + font-size: 0.95rem; + border-radius: var(--border-radius); + padding: 0.625rem 0; } .sidebar-filters .btn-primary { - background: var(--primary-color); - border: none; + background: var(--primary-color); + border: none; } .sidebar-filters .btn-primary:hover { - background: var(--primary-hover); + background: var(--primary-hover); } /* ================================ Table & Content ================================ */ .table thead th { - position: sticky; - top: 0; - background: var(--light-gray); - z-index: 1; - border-bottom: 2px solid var(--medium-gray); + position: sticky; + top: 0; + background: var(--light-gray); + z-index: 1; + border-bottom: 2px solid var(--medium-gray); } .table-scroll { - max-height: 60vh; - overflow-y: auto; + max-height: 60vh; + overflow-y: auto; } .capture-row { - cursor: pointer; + cursor: pointer; } .sortable-header { - transition: color var(--transition); + transition: color var(--transition); } .sortable-header:hover { - color: #007bff !important; + color: #007bff !important; } .sort-icon { - margin-left: 5px; - font-size: 0.8em; + margin-left: 5px; + font-size: 0.8em; } /* ================================ Dual Range Slider ================================ */ .dual-range-slider { - position: relative; - height: 6px; - background: var(--medium-gray); - border-radius: 3px; - margin: 20px 0; + position: relative; + height: 6px; + background: var(--medium-gray); + border-radius: 3px; + margin: 20px 0; } .dual-range-slider .slider-track { - position: absolute; - height: 100%; - background: var(--primary-color); - border-radius: 3px; - z-index: 1; - transition: all var(--transition); + position: absolute; + height: 100%; + background: var(--primary-color); + border-radius: 3px; + z-index: 1; + transition: all var(--transition); } .dual-range-slider input[type="range"] { - position: absolute; - width: 100%; - height: 6px; - background: transparent; - -webkit-appearance: none; - appearance: none; - pointer-events: none; - top: 0; + position: absolute; + width: 100%; + height: 6px; + background: transparent; + -webkit-appearance: none; + appearance: none; + pointer-events: none; + top: 0; } .dual-range-slider input[type="range"]::-webkit-slider-thumb { - -webkit-appearance: none; - width: 20px; - height: 20px; - border-radius: 50%; - background: var(--primary-color); - border: 2px solid #fff; - box-shadow: var(--shadow-sm); - cursor: pointer; - pointer-events: all; - position: relative; - z-index: 2; + -webkit-appearance: none; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--primary-color); + border: 2px solid #fff; + box-shadow: var(--shadow-sm); + cursor: pointer; + pointer-events: all; + position: relative; + z-index: 2; } .dual-range-slider input[type="range"]::-moz-range-thumb { - width: 20px; - height: 20px; - border-radius: 50%; - background: var(--primary-color); - border: 2px solid #fff; - box-shadow: var(--shadow-sm); - cursor: pointer; - pointer-events: all; - position: relative; - z-index: 2; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--primary-color); + border: 2px solid #fff; + box-shadow: var(--shadow-sm); + cursor: pointer; + pointer-events: all; + position: relative; + z-index: 2; } .dual-range-slider input[type="range"]::-webkit-slider-runnable-track, .dual-range-slider input[type="range"]::-moz-range-track { - background: transparent; + background: transparent; } .dual-range-values { - display: flex; - justify-content: space-between; - margin-top: 10px; - font-size: 0.875rem; - color: var(--text-muted); + display: flex; + justify-content: space-between; + margin-top: 10px; + font-size: 0.875rem; + color: var(--text-muted); } /* ================================ noUiSlider Custom Styling ================================ */ #frequency-range-slider { - margin: 1rem 0; + margin: 1rem 0; } /* Override global noUiSlider styles for frequency slider */ #frequency-range-slider.noUi-target { - background: var(--medium-gray); - border: none; - border-radius: var(--border-radius); - box-shadow: none; - height: 6px; + background: var(--medium-gray); + border: none; + border-radius: var(--border-radius); + box-shadow: none; + height: 6px; } #frequency-range-slider.noUi-horizontal { - height: 6px; + height: 6px; } /* Active/selected range */ #frequency-range-slider .noUi-connect { - background: var(--primary-color); - border-radius: var(--border-radius); - transition: background var(--transition); + background: var(--primary-color); + border-radius: var(--border-radius); + transition: background var(--transition); } /* Slider handles - override existing styles */ #frequency-range-slider .noUi-handle { - width: 18px; - height: 18px; - border: 2px solid #fff; - border-radius: 50%; - background: var(--primary-color); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); - cursor: pointer; - top: -6px; - right: -9px; - transition: all var(--transition); + width: 18px; + height: 18px; + border: 2px solid #fff; + border-radius: 50%; + background: var(--primary-color); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); + cursor: pointer; + top: -6px; + right: -9px; + transition: all var(--transition); } #frequency-range-slider .noUi-handle::before, #frequency-range-slider .noUi-handle::after { - display: none; - content: none; + display: none; + content: none; } #frequency-range-slider .noUi-handle:hover { - background: var(--primary-hover); - box-shadow: 0 2px 6px rgba(0, 90, 156, 0.25); - transform: scale(1.1); + background: var(--primary-hover); + box-shadow: 0 2px 6px rgba(0, 90, 156, 0.25); + transform: scale(1.1); } #frequency-range-slider .noUi-handle:focus { - outline: none; - background: var(--primary-hover); - box-shadow: 0 0 0 3px rgba(0, 90, 156, 0.2); + outline: none; + background: var(--primary-hover); + box-shadow: 0 0 0 3px rgba(0, 90, 156, 0.2); } #frequency-range-slider .noUi-handle:focus-visible { - outline: 2px solid var(--primary-color); - outline-offset: 2px; + outline: 2px solid var(--primary-color); + outline-offset: 2px; } /* Handle when active/dragging */ #frequency-range-slider .noUi-handle.noUi-active { - background: var(--primary-hover); - box-shadow: 0 2px 8px rgba(0, 90, 156, 0.3); - transform: scale(1.15); + background: var(--primary-hover); + box-shadow: 0 2px 8px rgba(0, 90, 156, 0.3); + transform: scale(1.15); } /* Capture list table column widths (matches capture_list_table_row.html) */ .capture-list-table { - table-layout: fixed; - width: 100%; + table-layout: fixed; + width: 100%; } .capture-list-table .capture-col-select { - width: 3rem; + width: 3rem; } .capture-list-table .capture-col-name { - width: 34%; + width: 34%; } .capture-list-table .capture-col-directory { - width: 22%; + width: 22%; } .capture-list-table .capture-col-type { - width: 14%; + width: 14%; } .capture-list-table .capture-col-created { - width: 20%; + width: 20%; } .capture-list-table .capture-col-actions { - width: 7rem; + width: 7rem; } /* ================================ Modal Styling ================================ */ .modal-dialog { - margin: 1.75rem auto; + margin: 1.75rem auto; } .modal-content { - border: none; - border-radius: 4px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + border: none; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); } .modal-header { - padding: 1.5rem 1.5rem 1rem; - border-bottom: 1px solid #dee2e6; - display: flex; - align-items: flex-start; - justify-content: space-between; + padding: 1.5rem 1.5rem 1rem; + border-bottom: 1px solid #dee2e6; + display: flex; + align-items: flex-start; + justify-content: space-between; } .modal-title { - font-weight: 400; - font-size: 1.5rem; - line-height: 1.2; - margin: 0; - padding-right: 1rem; + font-weight: 400; + font-size: 1.5rem; + line-height: 1.2; + margin: 0; + padding-right: 1rem; } .close { - float: right; - font-size: 1.5rem; - font-weight: 700; - line-height: 1; - color: #000; - text-shadow: 0 1px 0 #fff; - opacity: 0.5; - padding: 0; - background-color: transparent; - border: 0; - appearance: none; + float: right; + font-size: 1.5rem; + font-weight: 700; + line-height: 1; + color: #000; + text-shadow: 0 1px 0 #fff; + opacity: 0.5; + padding: 0; + background-color: transparent; + border: 0; + appearance: none; } .close:hover { - color: #000; - text-decoration: none; - opacity: 0.75; + color: #000; + text-decoration: none; + opacity: 0.75; } .close:focus { - outline: none; + outline: none; } .modal-body { - padding: 1rem; - background: #fff; + padding: 1rem; + background: #fff; } .modal-body h6 { - color: var(--primary-color); - font-weight: 700; - font-size: 1.1rem; - margin-bottom: 1rem; - padding-bottom: 0.5rem; - border-bottom: 2px solid var(--medium-gray); + color: var(--primary-color); + font-weight: 700; + font-size: 1.1rem; + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 2px solid var(--medium-gray); } .modal-body p { - margin-bottom: 0.75rem; - line-height: 1.6; + margin-bottom: 0.75rem; + line-height: 1.6; } .modal-body strong { - color: var(--text-secondary); - font-weight: 600; - display: inline-block; - min-width: 140px; + color: var(--text-secondary); + font-weight: 600; + display: inline-block; + min-width: 140px; } .modal-body .row + .row { - border-top: 1px solid var(--medium-gray); - padding-top: 1.5rem; + border-top: 1px solid var(--medium-gray); + padding-top: 1.5rem; } .modal-body .col-md-6:first-child { - border-right: 1px solid var(--medium-gray); - padding-right: 2rem; + border-right: 1px solid var(--medium-gray); + padding-right: 2rem; } .modal-body .col-md-6:last-child { - padding-left: 2rem; + padding-left: 2rem; } /* ================================ Pagination ================================ */ .pagination { - margin: 1rem 0; - justify-content: center; + margin: 1rem 0; + justify-content: center; } .pagination .page-link { - color: var(--primary-color); - border-color: var(--medium-gray); - padding: 0.5rem 1rem; - transition: all var(--transition); + color: var(--primary-color); + border-color: var(--medium-gray); + padding: 0.5rem 1rem; + transition: all var(--transition); } .pagination .page-link:hover { - background-color: var(--light-gray); - border-color: var(--primary-color); - color: var(--primary-color); + background-color: var(--light-gray); + border-color: var(--primary-color); + color: var(--primary-color); } .pagination .page-item.active .page-link { - background-color: var(--primary-color); - border-color: var(--primary-color); - color: #fff; + background-color: var(--primary-color); + border-color: var(--primary-color); + color: #fff; } .pagination .page-item.disabled .page-link { - color: var(--text-muted); - pointer-events: none; - background-color: #fff; - border-color: var(--medium-gray); + color: var(--text-muted); + pointer-events: none; + background-color: #fff; + border-color: var(--medium-gray); } /* ================================ Captures Container ================================ */ .captures-container { - display: flex; - flex-direction: column; - height: calc(100vh - 350px); - min-height: 400px; - max-height: calc(100vh - 350px); - background: #fff; - border-radius: var(--border-radius); - box-shadow: var(--shadow-sm); - position: relative; + display: flex; + flex-direction: column; + height: calc(100vh - 350px); + min-height: 400px; + max-height: calc(100vh - 350px); + background: #fff; + border-radius: var(--border-radius); + box-shadow: var(--shadow-sm); + position: relative; } .captures-container .card { - flex: 1; - margin-bottom: 0; - border: none; - display: flex; - flex-direction: column; - overflow: hidden; + flex: 1; + margin-bottom: 0; + border: none; + display: flex; + flex-direction: column; + overflow: hidden; } .captures-container .card-body { - flex: 1; - padding: 0; - display: flex; - flex-direction: column; - overflow: hidden; + flex: 1; + padding: 0; + display: flex; + flex-direction: column; + overflow: hidden; } .captures-container .table-responsive { - flex: 1; - overflow-y: auto; - height: calc(100% - 120px); /* Ensure space for pagination */ + flex: 1; + overflow-y: auto; + height: calc(100% - 120px); /* Ensure space for pagination */ } .captures-container .table { - margin-bottom: 0; + margin-bottom: 0; } /* Pagination container styling */ .captures-container nav { - padding: 1rem; - background: #fff; - border-top: 1px solid var(--medium-gray); - margin-top: auto; /* Push to bottom of container */ + padding: 1rem; + background: #fff; + border-top: 1px solid var(--medium-gray); + margin-top: auto; /* Push to bottom of container */ } /* ================================ Responsive Design ================================ */ @media (max-width: 768px) { - .modal-dialog { - max-width: 95%; - margin: 1rem auto; - } + .modal-dialog { + max-width: 95%; + margin: 1rem auto; + } - .modal-body .col-md-6:first-child { - border-right: none; - padding-right: 1rem; - margin-bottom: 1.5rem; - border-bottom: 1px solid var(--medium-gray); - padding-bottom: 1.5rem; - } + .modal-body .col-md-6:first-child { + border-right: none; + padding-right: 1rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--medium-gray); + padding-bottom: 1.5rem; + } - .modal-body .col-md-6:last-child { - padding-left: 1rem; - } + .modal-body .col-md-6:last-child { + padding-left: 1rem; + } - .sidebar-filters { - height: auto; - max-height: 500px; - } + .sidebar-filters { + height: auto; + max-height: 500px; + } } .items-per-page-select { - width: auto; + width: auto; } /* ================================ Upload Modal Input Groups ================================ */ .hidden-input-group { - display: none; + display: none; } /* ================================ Progress Bar Styles ================================ */ .progress-section-hidden { - display: none; + display: none; } .progress-bar-custom { - height: 8px; + height: 8px; } .progress-bar-width-0 { - width: 0%; + width: 0%; } /* Selection column: hidden by default, shown when selection mode is active */ #captures-table .capture-select-column { - display: none; - width: 2.5rem; - vertical-align: middle; + display: none; + width: 2.5rem; + vertical-align: middle; } #captures-table.selection-mode-active .capture-select-column { - display: table-cell; + display: table-cell; } diff --git a/gateway/sds_gateway/static/css/file-manager.css b/gateway/sds_gateway/static/css/file-manager.css index 100dd5288..15a5c0735 100644 --- a/gateway/sds_gateway/static/css/file-manager.css +++ b/gateway/sds_gateway/static/css/file-manager.css @@ -1,37 +1,37 @@ :root { - --color-text-primary: #3c4043; - --color-text-secondary: #5f6368; - --color-border: #dadce0; - --color-background: #fff; - --color-background-hover: #f8f9fa; - --color-primary: #1a73e8; - --color-primary-hover: #1557b0; - --font-family: "Google Sans", sans-serif; - --shadow-small: 0 1px 2px 0 rgba(60, 64, 67, 0.3), 0 1px 3px 1px - rgba(60, 64, 67, 0.15); - --shadow-medium: 0 1px 2px 0 rgba(60, 64, 67, 0.3), 0 2px 6px 2px - rgba(60, 64, 67, 0.15); - --transition-default: background-color 0.2s, box-shadow 0.2s, color 0.2s; - - /* Spacing and sizing variables */ - --spacing-xs: 4px; - --spacing-sm: 8px; - --spacing-md: 12px; - --spacing-lg: 16px; - --spacing-xl: 24px; - --spacing-2xl: 32px; - --spacing-3xl: 48px; - --spacing-4xl: 96px; - --spacing-5xl: 120px; - - /* Component sizing */ - --file-list-max-height: 320px; - --modal-max-width: 800px; - --button-height: 36px; - --header-height: 40px; - --breadcrumb-height: 32px; - --file-card-min-height: 40px; - --z-index-sticky: 10; + --color-text-primary: #3c4043; + --color-text-secondary: #5f6368; + --color-border: #dadce0; + --color-background: #fff; + --color-background-hover: #f8f9fa; + --color-primary: #1a73e8; + --color-primary-hover: #1557b0; + --font-family: "Google Sans", sans-serif; + --shadow-small: 0 1px 2px 0 rgba(60, 64, 67, 0.3), 0 1px 3px 1px + rgba(60, 64, 67, 0.15); + --shadow-medium: 0 1px 2px 0 rgba(60, 64, 67, 0.3), 0 2px 6px 2px + rgba(60, 64, 67, 0.15); + --transition-default: background-color 0.2s, box-shadow 0.2s, color 0.2s; + + /* Spacing and sizing variables */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 12px; + --spacing-lg: 16px; + --spacing-xl: 24px; + --spacing-2xl: 32px; + --spacing-3xl: 48px; + --spacing-4xl: 96px; + --spacing-5xl: 120px; + + /* Component sizing */ + --file-list-max-height: 320px; + --modal-max-width: 800px; + --button-height: 36px; + --header-height: 40px; + --breadcrumb-height: 32px; + --file-card-min-height: 40px; + --z-index-sticky: 10; } /* Accessibility */ @@ -41,118 +41,118 @@ .new-button:focus-visible, .upload-zone:focus-visible, .dropdown-item:focus-visible { - outline: 2px solid var(--color-primary); - outline-offset: 2px; - border-radius: 4px; + outline: 2px solid var(--color-primary); + outline-offset: 2px; + border-radius: 4px; } /* Reduced motion support */ @media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - scroll-behavior: auto !important; - } + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } } /* Layout */ /* Main container */ .files-container { - display: flex; - flex-direction: column; - height: auto; - min-height: 200px; - /* Minimum height to prevent empty state from looking too small */ - max-height: calc(100vh - 250px); - /* Maximum height based on viewport */ - overflow: auto; + display: flex; + flex-direction: column; + height: auto; + min-height: 200px; + /* Minimum height to prevent empty state from looking too small */ + max-height: calc(100vh - 250px); + /* Maximum height based on viewport */ + overflow: auto; } /* Files grid */ .files-grid { - display: flex; - flex-direction: column; - padding: 0; - background-color: var(--color-background); - border: 1px solid var(--color-border); - border-radius: 8px; - margin: 0 16px; - height: auto; - /* Let it grow based on content */ - min-height: 100px; - /* Minimum height for empty state */ + display: flex; + flex-direction: column; + padding: 0; + background-color: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 8px; + margin: 0 16px; + height: auto; + /* Let it grow based on content */ + min-height: 100px; + /* Minimum height for empty state */ } /* Individual file card */ .file-card { - border: none; - padding: 8px 16px; - cursor: pointer; - transition: var(--transition-default); - display: flex; - align-items: center; - text-align: left; - position: relative; - background-color: var(--color-background); - min-height: var(--file-card-min-height); - gap: 12px; - border-bottom: 1px solid var(--color-border); + border: none; + padding: 8px 16px; + cursor: pointer; + transition: var(--transition-default); + display: flex; + align-items: center; + text-align: left; + position: relative; + background-color: var(--color-background); + min-height: var(--file-card-min-height); + gap: 12px; + border-bottom: 1px solid var(--color-border); } .file-card:last-child { - border-bottom: none; + border-bottom: none; } .file-card:hover { - background-color: var(--color-background-hover); + background-color: var(--color-background-hover); } /* Clickable directory styling */ .clickable-directory { - cursor: pointer; + cursor: pointer; } .clickable-directory:hover { - background-color: var(--color-background-hover); - box-shadow: var(--shadow-small); + background-color: var(--color-background-hover); + box-shadow: var(--shadow-small); } .file-card-content { - display: flex; - align-items: center; - width: 100%; - gap: 16px; + display: flex; + align-items: center; + width: 100%; + gap: 16px; } /* File/Folder Names */ .file-name { - font-size: 0.875rem; - color: var(--color-text-primary); - margin: 0; - font-weight: 400; - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - padding-right: 24px; - display: inline-flex; - align-items: center; - gap: 6px; + font-size: 0.875rem; + color: var(--color-text-primary); + margin: 0; + font-weight: 400; + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-right: 24px; + display: inline-flex; + align-items: center; + gap: 6px; } /* Icons */ .file-icon, .folder-icon, .dataset-icon.bi { - font-size: 1.25rem; - color: var(--color-text-secondary); - margin-right: 12px; - flex-shrink: 0; - display: inline-block; - vertical-align: middle; + font-size: 1.25rem; + color: var(--color-text-secondary); + margin-right: 12px; + flex-shrink: 0; + display: inline-block; + vertical-align: middle; } /* Dataset icon styles are consolidated with other icons and hover rules */ @@ -161,399 +161,399 @@ .file-card:hover .file-name, .file-card:hover .folder-icon, .file-card:hover .file-icon { - color: #005a9c; - /* SpectrumX blue */ + color: #005a9c; + /* SpectrumX blue */ } /* No hover effects for H5 files */ .file-card[data-type="file"]:not([data-h5-file="true"]):hover .file-name, .file-card[data-type="file"]:not([data-h5-file="true"]):hover .file-icon { - color: #005a9c; - /* SpectrumX blue */ + color: #005a9c; + /* SpectrumX blue */ } /* Directory hover should also use the same blue */ .file-card[data-type="directory"]:hover .file-name { - color: #005a9c; + color: #005a9c; } .file-meta { - font-size: 0.8125rem; - color: var(--color-text-secondary); - white-space: nowrap; - flex-shrink: 0; - min-width: 150px; - /* Increased width for modified date */ - text-align: center; - /* Center align the modified date */ - display: flex; - align-items: center; - justify-content: center; - position: relative; - padding-right: 24px; - /* reserve space for right-aligned shared icon */ + font-size: 0.8125rem; + color: var(--color-text-secondary); + white-space: nowrap; + flex-shrink: 0; + min-width: 150px; + /* Increased width for modified date */ + text-align: center; + /* Center align the modified date */ + display: flex; + align-items: center; + justify-content: center; + position: relative; + padding-right: 24px; + /* reserve space for right-aligned shared icon */ } /* Keep the shared-with icon from shifting the centered date */ .file-shared { - font-size: 0.8125rem; - color: var(--color-text-secondary); - white-space: nowrap; - flex-shrink: 0; - min-width: 120px; - /* Fixed width for shared by column */ - text-align: center; - /* Center align shared by */ - margin-left: 0; - /* Remove offset that pushes content */ - display: flex; - align-items: center; - justify-content: center; + font-size: 0.8125rem; + color: var(--color-text-secondary); + white-space: nowrap; + flex-shrink: 0; + min-width: 120px; + /* Fixed width for shared by column */ + text-align: center; + /* Center align shared by */ + margin-left: 0; + /* Remove offset that pushes content */ + display: flex; + align-items: center; + justify-content: center; } .file-actions { - font-size: 0.8125rem; - white-space: nowrap; - flex-shrink: 0; - min-width: 80px; - /* Fixed width for actions column */ - text-align: center; - /* Center align actions */ - display: flex; - align-items: center; - justify-content: center; + font-size: 0.8125rem; + white-space: nowrap; + flex-shrink: 0; + min-width: 80px; + /* Fixed width for actions column */ + text-align: center; + /* Center align actions */ + display: flex; + align-items: center; + justify-content: center; } /* Directory Specific */ .file-card[data-type="directory"] { - font-weight: 500; + font-weight: 500; } /* Header row */ .file-card.header { - padding: 12px 16px; - height: var(--header-height); - border-bottom: 1px solid var(--color-border); - font-weight: 500; - color: var(--color-text-secondary); - cursor: default; - position: sticky; - top: 0; - z-index: var(--z-index-sticky); - background-color: #f8f9fa; - border-top-left-radius: 8px; - border-top-right-radius: 8px; + padding: 12px 16px; + height: var(--header-height); + border-bottom: 1px solid var(--color-border); + font-weight: 500; + color: var(--color-text-secondary); + cursor: default; + position: sticky; + top: 0; + z-index: var(--z-index-sticky); + background-color: #f8f9fa; + border-top-left-radius: 8px; + border-top-right-radius: 8px; } .file-card.header:hover { - background-color: #f8f9fa; + background-color: #f8f9fa; } .file-card.header .file-name { - font-weight: 500; - color: var(--color-text-secondary); - font-size: 0.8125rem; - text-transform: none; - letter-spacing: normal; + font-weight: 500; + color: var(--color-text-secondary); + font-size: 0.8125rem; + text-transform: none; + letter-spacing: normal; } .file-card.header .file-meta, .file-card.header .file-shared { - font-size: 0.8125rem; - text-transform: none; - letter-spacing: normal; - color: var(--color-text-secondary); + font-size: 0.8125rem; + text-transform: none; + letter-spacing: normal; + color: var(--color-text-secondary); } /* Breadcrumb Navigation */ .breadcrumb { - display: flex; - align-items: center; - list-style: none; - margin: 0; - padding: 0; - font-size: 0.875rem; - height: var(--breadcrumb-height); - font-family: var(--font-family); - font-weight: 400; - /* Add normal font weight */ + display: flex; + align-items: center; + list-style: none; + margin: 0; + padding: 0; + font-size: 0.875rem; + height: var(--breadcrumb-height); + font-family: var(--font-family); + font-weight: 400; + /* Add normal font weight */ } .breadcrumb-item { - color: var(--color-text-secondary); - text-decoration: none; - cursor: pointer; - display: flex; - align-items: center; - height: var(--breadcrumb-height); - padding: 0 4px; - border-radius: 4px; - font-family: var(--font-family); - font-weight: 400; - /* Add normal font weight */ + color: var(--color-text-secondary); + text-decoration: none; + cursor: pointer; + display: flex; + align-items: center; + height: var(--breadcrumb-height); + padding: 0 4px; + border-radius: 4px; + font-family: var(--font-family); + font-weight: 400; + /* Add normal font weight */ } .breadcrumb-item:hover { - background-color: var(--color-background-hover); + background-color: var(--color-background-hover); } .breadcrumb-item a { - color: var(--color-text-secondary); - text-decoration: none; - padding: 0 4px; - font-family: var(--font-family); - font-weight: 400; - /* Add normal font weight */ + color: var(--color-text-secondary); + text-decoration: none; + padding: 0 4px; + font-family: var(--font-family); + font-weight: 400; + /* Add normal font weight */ } .breadcrumb-item:hover a { - color: var(--color-text-primary); - text-decoration: none; + color: var(--color-text-primary); + text-decoration: none; } /* Empty State */ .no-files { - text-align: center; - padding: var(--spacing-5xl) 20px; - color: var(--color-text-secondary); - background-color: var(--color-background); - margin: 0; - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; + text-align: center; + padding: var(--spacing-5xl) 20px; + color: var(--color-text-secondary); + background-color: var(--color-background); + margin: 0; + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; } .empty-folder-icon { - font-size: var(--spacing-4xl); - margin-bottom: 24px; - color: var(--color-border); + font-size: var(--spacing-4xl); + margin-bottom: 24px; + color: var(--color-border); } /* Upload Zone */ .upload-zone { - border: 2px dashed #ccc; - border-radius: 8px; - padding: 2rem; - text-align: center; - cursor: pointer; - transition: all 0.3s ease; - user-select: none; + border: 2px dashed #ccc; + border-radius: 8px; + padding: 2rem; + text-align: center; + cursor: pointer; + transition: all 0.3s ease; + user-select: none; } .upload-zone:hover, .upload-zone.drag-over { - border-color: #0d6efd; - background-color: rgba(13, 110, 253, 0.05); + border-color: #0d6efd; + background-color: rgba(13, 110, 253, 0.05); } .upload-zone-icon { - font-size: 3rem; - color: #6c757d; - margin-bottom: 1rem; + font-size: 3rem; + color: #6c757d; + margin-bottom: 1rem; } .upload-zone-text { - font-size: 1.1rem; - margin-bottom: 0.5rem; + font-size: 1.1rem; + margin-bottom: 0.5rem; } .upload-zone-subtext { - color: #6c757d; - font-size: 0.9rem; + color: #6c757d; + font-size: 0.9rem; } .browse-button { - color: #0d6efd; - text-decoration: underline; - cursor: pointer; + color: #0d6efd; + text-decoration: underline; + cursor: pointer; } .browse-button:hover { - text-decoration: none; + text-decoration: none; } /* Selected Files */ .selected-files { - margin-top: 1rem; - display: none; + margin-top: 1rem; + display: none; } .selected-files.has-files { - display: block; + display: block; } .selected-files-header { - font-weight: 500; - margin-bottom: 0.5rem; + font-weight: 500; + margin-bottom: 0.5rem; } .selected-files-list { - list-style: none; - padding: 0; - margin: 0; - max-height: var(--file-list-max-height); - /* taller list for large folder previews */ - overflow-y: auto; - border: 1px solid #dee2e6; - border-radius: 4px; - padding: 0.5rem; + list-style: none; + padding: 0; + margin: 0; + max-height: var(--file-list-max-height); + /* taller list for large folder previews */ + overflow-y: auto; + border: 1px solid #dee2e6; + border-radius: 4px; + padding: 0.5rem; } .selected-files-list li { - display: flex; - align-items: center; - padding: 0.25rem 0; + display: flex; + align-items: center; + padding: 0.25rem 0; } .selected-files-list li i { - margin-right: 0.5rem; - color: #6c757d; + margin-right: 0.5rem; + color: #6c757d; } .selected-files-list li ul { - list-style: none; - padding-left: 1.5rem; - margin: 0; + list-style: none; + padding-left: 1.5rem; + margin: 0; } .upload-spinner { - display: inline-flex; - align-items: center; + display: inline-flex; + align-items: center; } /* Modal Styles */ .modal-content { - border-radius: 8px; - border: none; + border-radius: 8px; + border: none; } .modal-header { - border-bottom: 1px solid var(--color-border); - padding: 16px 24px; + border-bottom: 1px solid var(--color-border); + padding: 16px 24px; } .modal-title { - font-family: var(--font-family); - font-size: 1rem; - font-weight: 500; - color: var(--color-text-primary); + font-family: var(--font-family); + font-size: 1rem; + font-weight: 500; + color: var(--color-text-primary); } .modal-body { - padding: 24px; + padding: 24px; } .modal-footer { - border-top: 1px solid var(--color-border); - padding: 16px 24px; + border-top: 1px solid var(--color-border); + padding: 16px 24px; } /* Button Styles (scoped to files page content only to avoid modal conflicts) */ /* Scope button tweaks to the files page action bar only to avoid bleed into modals */ .files-actions-bar .btn-primary, .files-actions-bar .btn-secondary { - font-family: var(--font-family); - font-weight: 500; + font-family: var(--font-family); + font-weight: 500; } .files-actions-bar .btn-primary { - background-color: var(--color-primary); - border-color: var(--color-primary); + background-color: var(--color-primary); + border-color: var(--color-primary); } .files-actions-bar .btn-primary:hover { - background-color: var(--color-primary-hover); - border-color: var(--color-primary-hover); + background-color: var(--color-primary-hover); + border-color: var(--color-primary-hover); } .files-actions-bar .btn-secondary { - color: var(--color-primary); - background-color: transparent; - border-color: transparent; + color: var(--color-primary); + background-color: transparent; + border-color: transparent; } .files-actions-bar .btn-secondary:hover { - color: var(--color-primary-hover); - background-color: var(--color-background-hover); - border-color: transparent; + color: var(--color-primary-hover); + background-color: var(--color-background-hover); + border-color: transparent; } /* Form Styles */ .form-control { - border-radius: 4px; - border-color: var(--color-border); - padding: 8px 12px; + border-radius: 4px; + border-color: var(--color-border); + padding: 8px 12px; } .form-control:focus { - border-color: var(--color-primary); - box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.2); + border-color: var(--color-primary); + box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.2); } .form-label { - font-size: 0.875rem; - color: var(--color-text-secondary); - margin-bottom: 8px; + font-size: 0.875rem; + color: var(--color-text-secondary); + margin-bottom: 8px; } .form-text { - font-size: 0.75rem; - color: var(--color-text-secondary); + font-size: 0.75rem; + color: var(--color-text-secondary); } .file-card[data-type="directory"] .folder-icon { - margin-left: 0; + margin-left: 0; } /* Adjust size of Bootstrap Icons */ .bi { - font-size: 1.25rem; - line-height: 1; - vertical-align: middle; + font-size: 1.25rem; + line-height: 1; + vertical-align: middle; } .preview-content { - max-height: 500px; - overflow: auto; - border: 1px solid #e9ecef; - border-radius: 4px; - background: #f8f9fa; + max-height: 500px; + overflow: auto; + border: 1px solid #e9ecef; + border-radius: 4px; + background: #f8f9fa; } .preview-text { - margin: 0; - padding: 1rem; - white-space: pre-wrap; - word-wrap: break-word; - font-family: monospace; - font-size: 0.875rem; - line-height: 1.5; - color: #212529; + margin: 0; + padding: 1rem; + white-space: pre-wrap; + word-wrap: break-word; + font-family: monospace; + font-size: 0.875rem; + line-height: 1.5; + color: #212529; } /* Enhanced syntax highlighting styles */ .syntax-highlighted { - margin: 0; - padding: 0; - border-radius: 6px; - overflow: hidden; - background: #f8f9fa; - border: 1px solid #e9ecef; + margin: 0; + padding: 0; + border-radius: 6px; + overflow: hidden; + background: #f8f9fa; + border: 1px solid #e9ecef; } .syntax-highlighted code { - font-family: "Fira Code", "Monaco", "Consolas", "Liberation Mono", - "Courier New", monospace; - font-size: 0.8125rem; - line-height: 1.6; - padding: 1rem; - display: block; - overflow-x: auto; - white-space: pre; - background: transparent; + font-family: "Fira Code", "Monaco", "Consolas", "Liberation Mono", + "Courier New", monospace; + font-size: 0.8125rem; + line-height: 1.6; + padding: 1rem; + display: block; + overflow-x: auto; + white-space: pre; + background: transparent; } /* Custom Prism.js theme overrides for better readability */ @@ -561,426 +561,426 @@ .syntax-highlighted .token.prolog, .syntax-highlighted .token.doctype, .syntax-highlighted .token.cdata { - color: #6a737d; - font-style: italic; + color: #6a737d; + font-style: italic; } .syntax-highlighted .token.string, .syntax-highlighted .token.attr-value { - color: #032f62; + color: #032f62; } .syntax-highlighted .token.keyword, .syntax-highlighted .token.operator { - color: #d73a49; + color: #d73a49; } .syntax-highlighted .token.function, .syntax-highlighted .token.class-name { - color: #6f42c1; + color: #6f42c1; } .syntax-highlighted .token.number, .syntax-highlighted .token.boolean { - color: #005cc5; + color: #005cc5; } .syntax-highlighted .token.variable { - color: #24292e; + color: #24292e; } .syntax-highlighted .token.property { - color: #005cc5; + color: #005cc5; } .syntax-highlighted .token.tag { - color: #22863a; + color: #22863a; } .syntax-highlighted .token.attr-name { - color: #6f42c1; + color: #6f42c1; } /* File preview modal enhancements */ #filePreviewModal .modal-body { - padding: 0; + padding: 0; } #filePreviewModal .preview-content { - max-height: 70vh; - overflow: auto; + max-height: 70vh; + overflow: auto; } /* Jupyter Notebook Preview Styles */ .jupyter-notebook-preview { - font-family: var(--font-family); - background: #fff; - border-radius: 8px; - overflow: hidden; + font-family: var(--font-family); + background: #fff; + border-radius: 8px; + overflow: hidden; } .notebook-header { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - padding: 20px; - border-bottom: 1px solid #e9ecef; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 20px; + border-bottom: 1px solid #e9ecef; } .notebook-title { - font-size: 1.5rem; - font-weight: 600; - margin-bottom: 8px; - display: flex; - align-items: center; - gap: 12px; + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 12px; } .notebook-title i { - font-size: 1.75rem; - color: #ffd700; + font-size: 1.75rem; + color: #ffd700; } .notebook-info { - display: flex; - gap: 20px; - font-size: 0.875rem; - opacity: 0.9; + display: flex; + gap: 20px; + font-size: 0.875rem; + opacity: 0.9; } .notebook-kernel { - background: rgba(255, 255, 255, 0.2); - padding: 4px 12px; - border-radius: 16px; - font-weight: 500; + background: rgba(255, 255, 255, 0.2); + padding: 4px 12px; + border-radius: 16px; + font-weight: 500; } .notebook-cells { - background: rgba(255, 255, 255, 0.2); - padding: 4px 12px; - border-radius: 16px; - font-weight: 500; + background: rgba(255, 255, 255, 0.2); + padding: 4px 12px; + border-radius: 16px; + font-weight: 500; } .notebook-cell { - margin: 0; - border-bottom: 1px solid #f0f0f0; + margin: 0; + border-bottom: 1px solid #f0f0f0; } .notebook-cell:last-child { - border-bottom: none; + border-bottom: none; } .cell-header { - background: #f8f9fa; - padding: 8px 16px; - border-left: 4px solid transparent; - display: flex; - align-items: center; - gap: 12px; - font-size: 0.8125rem; - font-weight: 500; + background: #f8f9fa; + padding: 8px 16px; + border-left: 4px solid transparent; + display: flex; + align-items: center; + gap: 12px; + font-size: 0.8125rem; + font-weight: 500; } .notebook-cell.code .cell-header { - border-left-color: #007acc; + border-left-color: #007acc; } .notebook-cell.markdown .cell-header { - border-left-color: #28a745; + border-left-color: #28a745; } .cell-type { - padding: 2px 8px; - border-radius: 12px; - font-size: 0.6875rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; + padding: 2px 8px; + border-radius: 12px; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; } .cell-type.code { - background: #e3f2fd; - color: #1976d2; + background: #e3f2fd; + color: #1976d2; } .cell-type.markdown { - background: #e8f5e8; - color: #2e7d32; + background: #e8f5e8; + color: #2e7d32; } .execution-count { - color: #666; - font-family: "Monaco", "Consolas", monospace; + color: #666; + font-family: "Monaco", "Consolas", monospace; } .cell-content { - padding: 16px; + padding: 16px; } .cell-content pre { - margin: 0; - background: #f8f9fa; - border: 1px solid #e9ecef; - border-radius: 6px; - padding: 12px; - font-size: 0.8125rem; - line-height: 1.5; - overflow-x: auto; + margin: 0; + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 6px; + padding: 12px; + font-size: 0.8125rem; + line-height: 1.5; + overflow-x: auto; } .cell-content code { - background: transparent; - padding: 0; - border: none; - font-size: inherit; + background: transparent; + padding: 0; + border: none; + font-size: inherit; } .markdown-content { - line-height: 1.6; - color: #333; + line-height: 1.6; + color: #333; } .cell-output { - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid #e9ecef; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #e9ecef; } .output-label { - font-size: 0.75rem; - font-weight: 600; - color: #666; - margin-bottom: 8px; - display: block; + font-size: 0.75rem; + font-weight: 600; + color: #666; + margin-bottom: 8px; + display: block; } .output-stream { - background: #f5f5f5; - border: 1px solid #e0e0e0; - border-radius: 4px; - padding: 8px; - font-family: "Monaco", "Consolas", monospace; - font-size: 0.75rem; - color: #333; + background: #f5f5f5; + border: 1px solid #e0e0e0; + border-radius: 4px; + padding: 8px; + font-family: "Monaco", "Consolas", monospace; + font-size: 0.75rem; + color: #333; } .output-result { - background: #e8f5e8; - border: 1px solid #c8e6c9; - border-radius: 4px; - padding: 8px; - font-family: "Monaco", "Consolas", monospace; - font-size: 0.75rem; - color: #2e7d32; + background: #e8f5e8; + border: 1px solid #c8e6c9; + border-radius: 4px; + padding: 8px; + font-family: "Monaco", "Consolas", monospace; + font-size: 0.75rem; + color: #2e7d32; } /* Make modal larger for previews */ #filePreviewModal .modal-dialog { - max-width: var(--modal-max-width); + max-width: var(--modal-max-width); } /* Main Navigation styles are handled by base.html */ /* Actions Bar (scoped) */ .files-actions-bar { - margin: 24px 16px; - /* Keep the margin */ - display: flex; - justify-content: flex-start; - /* Align to the left */ - align-items: center; + margin: 24px 16px; + /* Keep the margin */ + display: flex; + justify-content: flex-start; + /* Align to the left */ + align-items: center; } /* New Button & Menu */ .new-button { - background-color: var(--color-background); - border: 1px solid var(--color-border); - border-radius: 8px; - color: var(--color-text-primary); - cursor: pointer; - font-family: var(--font-family); - font-size: 0.875rem; - font-weight: 500; - height: var(--button-height); - padding: 0 16px; - display: flex; - align-items: center; - gap: 8px; - transition: var(--transition-default); - min-width: 100px; - /* Give the button a minimum width */ - justify-content: center; - /* Center the button content */ + background-color: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 8px; + color: var(--color-text-primary); + cursor: pointer; + font-family: var(--font-family); + font-size: 0.875rem; + font-weight: 500; + height: var(--button-height); + padding: 0 16px; + display: flex; + align-items: center; + gap: 8px; + transition: var(--transition-default); + min-width: 100px; + /* Give the button a minimum width */ + justify-content: center; + /* Center the button content */ } .new-button:hover { - background-color: var(--color-background-hover); - box-shadow: var(--shadow-small); + background-color: var(--color-background-hover); + box-shadow: var(--shadow-small); } .new-button i { - font-size: 1.25rem; - color: var(--color-text-primary); + font-size: 1.25rem; + color: var(--color-text-primary); } .new-menu { - border-radius: 8px; - box-shadow: var(--shadow-medium); - padding: 8px 0; - min-width: 200px; - border: 1px solid var(--color-border); - margin-top: 4px; - background-color: var(--color-background); + border-radius: 8px; + box-shadow: var(--shadow-medium); + padding: 8px 0; + min-width: 200px; + border: 1px solid var(--color-border); + margin-top: 4px; + background-color: var(--color-background); } .new-menu .dropdown-item { - color: var(--color-text-primary); - font-size: 0.875rem; - padding: 8px 16px; - display: flex; - align-items: center; - gap: 12px; - font-family: var(--font-family); + color: var(--color-text-primary); + font-size: 0.875rem; + padding: 8px 16px; + display: flex; + align-items: center; + gap: 12px; + font-family: var(--font-family); } .new-menu .dropdown-item:hover { - background-color: var(--color-background-hover); + background-color: var(--color-background-hover); } .new-menu .dropdown-item i { - font-size: 1.25rem; - color: var(--color-text-secondary); - width: 20px; - text-align: center; - margin-right: 12px; - flex-shrink: 0; - display: inline-block; - vertical-align: middle; + font-size: 1.25rem; + color: var(--color-text-secondary); + width: 20px; + text-align: center; + margin-right: 12px; + flex-shrink: 0; + display: inline-block; + vertical-align: middle; } .new-menu .dropdown-item:hover i { - color: var(--color-text-primary); + color: var(--color-text-primary); } /* Ensure Material Icons align properly in dropdown */ .new-menu .dropdown-item .material-icons { - font-size: 1.25rem; - line-height: 1; - vertical-align: middle; - color: var(--color-text-secondary); + font-size: 1.25rem; + line-height: 1; + vertical-align: middle; + color: var(--color-text-secondary); } /* Responsive adjustments */ @media (max-width: 767.98px) { - /* Containers and spacing */ - .files-actions-bar { - margin: 16px 8px; - } - - .files-grid { - margin: 0 8px; - } - - /* Compact file rows */ - .file-card { - padding: 8px 12px; - gap: 8px; - } - - .file-name { - padding-right: 0; - } - - /* Hide secondary columns to prevent overflow */ - .file-meta, - .file-shared { - display: none; - } - - .file-card.header .file-meta, - .file-card.header .file-shared { - display: none; - } - - /* Upload area sizing */ - .upload-zone { - padding: 1rem; - } - - .upload-zone-icon { - font-size: 2.4rem; - } - - .selected-files-list { - max-height: 220px; - } - - /* Breadcrumbs wrap nicely */ - .breadcrumb { - flex-wrap: wrap; - height: auto; - row-gap: 4px; - } - - .breadcrumb-item { - height: auto; - } + /* Containers and spacing */ + .files-actions-bar { + margin: 16px 8px; + } + + .files-grid { + margin: 0 8px; + } + + /* Compact file rows */ + .file-card { + padding: 8px 12px; + gap: 8px; + } + + .file-name { + padding-right: 0; + } + + /* Hide secondary columns to prevent overflow */ + .file-meta, + .file-shared { + display: none; + } + + .file-card.header .file-meta, + .file-card.header .file-shared { + display: none; + } + + /* Upload area sizing */ + .upload-zone { + padding: 1rem; + } + + .upload-zone-icon { + font-size: 2.4rem; + } + + .selected-files-list { + max-height: 220px; + } + + /* Breadcrumbs wrap nicely */ + .breadcrumb { + flex-wrap: wrap; + height: auto; + row-gap: 4px; + } + + .breadcrumb-item { + height: auto; + } } @media (max-width: 575.98px) { - /* Modal paddings on very small screens */ - .modal-header, - .modal-footer { - padding: 12px 16px; - } + /* Modal paddings on very small screens */ + .modal-header, + .modal-footer { + padding: 12px 16px; + } - .modal-body { - padding: 16px; - } + .modal-body { + padding: 16px; + } - .modal-dialog { - margin: 0.5rem; - } + .modal-dialog { + margin: 0.5rem; + } } /* Global Drop Zone Overlay */ .global-drop-zone { - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background: rgba(0, 123, 255, 0.1); - border: 3px dashed #007bff; - z-index: 9999; - display: flex; - align-items: center; - justify-content: center; - backdrop-filter: blur(2px); + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 123, 255, 0.1); + border: 3px dashed #007bff; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(2px); } .global-drop-zone-content { - text-align: center; - background: white; - padding: 3rem; - border-radius: 1rem; - box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.15); + text-align: center; + background: white; + padding: 3rem; + border-radius: 1rem; + box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.15); } .global-drop-zone-icon { - font-size: 4rem; - color: #007bff; - margin-bottom: 1rem; + font-size: 4rem; + color: #007bff; + margin-bottom: 1rem; } .global-drop-zone-text { - font-size: 1.5rem; - font-weight: 600; - color: #333; - margin-bottom: 0.5rem; + font-size: 1.5rem; + font-weight: 600; + color: #333; + margin-bottom: 0.5rem; } .global-drop-zone-subtext { - font-size: 1rem; - color: #666; + font-size: 1rem; + color: #666; } diff --git a/gateway/sds_gateway/static/css/layout.css b/gateway/sds_gateway/static/css/layout.css index dbed50a29..bbd3ba389 100644 --- a/gateway/sds_gateway/static/css/layout.css +++ b/gateway/sds_gateway/static/css/layout.css @@ -1,232 +1,232 @@ html, body { - height: 100%; + height: 100%; } body { - font-family: "Roboto", sans-serif; - font-weight: 300; - line-height: 1.6; - color: var(--text-color); - background-color: var(--white); - margin: 0; - padding: 0; - min-height: 100vh; - display: flex; - flex-direction: column; - position: relative; /* Add this */ + font-family: "Roboto", sans-serif; + font-weight: 300; + line-height: 1.6; + color: var(--text-color); + background-color: var(--white); + margin: 0; + padding: 0; + min-height: 100vh; + display: flex; + flex-direction: column; + position: relative; /* Add this */ } .container { - max-width: 95vw; - margin: 0 auto; - padding: 0 10px; + max-width: 95vw; + margin: 0 auto; + padding: 0 10px; } .container-white-page .container { - background-color: var(--white); - color: var(--text-color); /* Ensure text is visible */ + background-color: var(--white); + color: var(--text-color); /* Ensure text is visible */ } /* Rainbow Bar */ .rainbow-bar { - height: 4px; - width: 100%; - background: linear-gradient( - to right, - #ff0000 /* Red */, - #ff7f00 /* Orange */, - #ffff00 /* Yellow */, - #00ff00 /* Green */, - #0000ff /* Blue */, - #4b0082 /* Indigo */, - #8f00ff /* Violet */ - ); - position: absolute; /* Change to absolute */ - top: 0; - left: 0; - z-index: 1001; + height: 4px; + width: 100%; + background: linear-gradient( + to right, + #ff0000 /* Red */, + #ff7f00 /* Orange */, + #ffff00 /* Yellow */, + #00ff00 /* Green */, + #0000ff /* Blue */, + #4b0082 /* Indigo */, + #8f00ff /* Violet */ + ); + position: absolute; /* Change to absolute */ + top: 0; + left: 0; + z-index: 1001; } /* Header Styles */ .site-header { - background: var(--white); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04); - width: 100%; - z-index: 10; - padding: 0; - position: relative; + background: var(--white); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04); + width: 100%; + z-index: 10; + padding: 0; + position: relative; } /* Ensure gradient bar is visible in hero-white-page */ .hero-white-page .top-gradient-bar { - display: block !important; - visibility: visible !important; + display: block !important; + visibility: visible !important; } .header-content { - width: 100%; - display: flex; - justify-content: space-between; - align-items: center; - padding: 1rem 0; - gap: 2rem; + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 0; + gap: 2rem; } /* logo container to take more space */ .header-content > div:first-child { - flex: 0 0 auto; - min-width: 200px; - padding-left: 0.5rem; - flex-grow: 1; + flex: 0 0 auto; + min-width: 200px; + padding-left: 0.5rem; + flex-grow: 1; } /* Navigation container */ .header-content > div:last-child { - min-width: 200px; - padding-right: 1rem; + min-width: 200px; + padding-right: 1rem; } .logo { - display: flex; - align-items: center; - gap: 2rem; - /* a gap between the logo image and the site name */ + display: flex; + align-items: center; + gap: 2rem; + /* a gap between the logo image and the site name */ } .logo img { - height: 30px; + height: 30px; } main { - flex: 1 0 auto; - display: flex; - flex-direction: column; - background-color: var(--white); - /* Remove padding-top since header is no longer fixed */ - padding-top: 0; - margin-top: 0; + flex: 1 0 auto; + display: flex; + flex-direction: column; + background-color: var(--white); + /* Remove padding-top since header is no longer fixed */ + padding-top: 0; + margin-top: 0; } .main-white-page main { - background-color: var(--white); - color: var(--text-color); /* Ensure text is visible */ + background-color: var(--white); + color: var(--text-color); /* Ensure text is visible */ } /* Added to remove underline/hyperlink from website name*/ .no-underline { - text-decoration: none; + text-decoration: none; } .no-underline:hover, .no-underline:focus, .no-underline:active { - text-decoration: none; + text-decoration: none; } /* Center the brand section */ .navbar-brand { - display: flex; - align-items: center; + display: flex; + align-items: center; } /* Style the logo text - Updated to match SpectrumX theme */ .navbar-brand .system-name { - font-size: 1.2rem; - font-weight: 300; - color: #495057; + font-size: 1.2rem; + font-weight: 300; + color: #495057; } @media (max-width: 768px) { - .navbar-brand { - /* Reset any mobile-specific styles */ - } + .navbar-brand { + /* Reset any mobile-specific styles */ + } } /* Navigation */ .main-nav { - display: flex; - gap: 2rem; - padding: 0; + display: flex; + gap: 2rem; + padding: 0; } .main-nav ul { - list-style: none; - display: flex; - gap: 1.5rem; - margin-bottom: 0; - padding-left: 0; + list-style: none; + display: flex; + gap: 1.5rem; + margin-bottom: 0; + padding-left: 0; } .main-nav a { - text-decoration: none; - color: var(--text-color); - font-weight: 500; - transition: color 0.3s; - align-content: center; + text-decoration: none; + color: var(--text-color); + font-weight: 500; + transition: color 0.3s; + align-content: center; } .main-nav a:hover { - color: var(--secondary-color); + color: var(--secondary-color); } h2 { - text-align: center; + text-align: center; } .center-heading { - display: flex; - justify-content: center; - align-items: center; - height: 100px; /* Adjust based on your layout */ - text-align: center; + display: flex; + justify-content: center; + align-items: center; + height: 100px; /* Adjust based on your layout */ + text-align: center; } .styled-paragraph { - margin-bottom: 1.0rem; - line-height: 1.6; + margin-bottom: 1.0rem; + line-height: 1.6; } .centered-list { - text-align: center; - list-style: none; - padding: 0; - margin: 0; + text-align: center; + list-style: none; + padding: 0; + margin: 0; } /* Page wrapper for sticky footer */ .page-wrapper { - flex: 1 0 auto; - display: flex; - flex-direction: column; - min-height: 0; - padding-top: 4px; /* Add padding to account for gradient bar */ + flex: 1 0 auto; + display: flex; + flex-direction: column; + min-height: 0; + padding-top: 4px; /* Add padding to account for gradient bar */ } /* Footer */ .site-footer { - background: #000; - width: 100%; - color: var(--white); - padding: 1rem 0; - margin-top: auto; /* This pushes the footer to the bottom when content is short */ + background: #000; + width: 100%; + color: var(--white); + padding: 1rem 0; + margin-top: auto; /* This pushes the footer to the bottom when content is short */ } .footer-content { - display: grid; - grid-template-columns: 1fr 2fr 1fr; - gap: 2rem; + display: grid; + grid-template-columns: 1fr 2fr 1fr; + gap: 2rem; } .footer-logos { - display: flex; - gap: 2rem; + display: flex; + gap: 2rem; } .footer-logos img { - height: 40px; + height: 40px; } .left-title { - text-align: left; + text-align: left; } diff --git a/gateway/sds_gateway/static/css/permission-levels.css b/gateway/sds_gateway/static/css/permission-levels.css index b074ce935..e7b99794d 100644 --- a/gateway/sds_gateway/static/css/permission-levels.css +++ b/gateway/sds_gateway/static/css/permission-levels.css @@ -2,513 +2,513 @@ /* Permission level badges */ .permission-badge { - font-size: 0.75rem; - font-weight: 500; - padding: 0.25rem 0.5rem; - border-radius: 0.375rem; - display: inline-flex; - align-items: center; - gap: 0.25rem; - white-space: nowrap; + font-size: 0.75rem; + font-weight: 500; + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + display: inline-flex; + align-items: center; + gap: 0.25rem; + white-space: nowrap; } .permission-badge.viewer { - background-color: var(--spectrumx-viewer-bg); - color: var(--spectrumx-viewer-color); - border: 1px solid var(--spectrumx-viewer-border); + background-color: var(--spectrumx-viewer-bg); + color: var(--spectrumx-viewer-color); + border: 1px solid var(--spectrumx-viewer-border); } .permission-badge.contributor { - background-color: var(--spectrumx-contributor-bg); - color: var(--spectrumx-contributor-color); - border: 1px solid var(--spectrumx-contributor-border); + background-color: var(--spectrumx-contributor-bg); + color: var(--spectrumx-contributor-color); + border: 1px solid var(--spectrumx-contributor-border); } .permission-badge.co-owner { - background-color: var(--spectrumx-co-owner-bg); - color: var(--spectrumx-co-owner-color); - border: 1px solid var(--spectrumx-co-owner-border); + background-color: var(--spectrumx-co-owner-bg); + color: var(--spectrumx-co-owner-color); + border: 1px solid var(--spectrumx-co-owner-border); } .permission-badge.owner { - background-color: var(--spectrumx-owner-bg); - color: var(--spectrumx-owner-color); - border: 1px solid var(--spectrumx-owner-border); + background-color: var(--spectrumx-owner-bg); + color: var(--spectrumx-owner-color); + border: 1px solid var(--spectrumx-owner-border); } .bg-owner { - background-color: var(--spectrumx-owner-color); - color: white; - border: 1px solid var(--spectrumx-owner-border); + background-color: var(--spectrumx-owner-color); + color: white; + border: 1px solid var(--spectrumx-owner-border); } /* Permission level selection form */ .permission-level-section { - background-color: var(bs-gray-100); - border: 1px solid var(--bs-gray-300); - border-radius: 0.5rem; - padding: 1rem; + background-color: var(bs-gray-100); + border: 1px solid var(--bs-gray-300); + border-radius: 0.5rem; + padding: 1rem; } .permission-level-section .form-label { - color: var(--bs-gray-700); - margin-bottom: 0.5rem; + color: var(--bs-gray-700); + margin-bottom: 0.5rem; } .permission-level-section .form-select { - border-color: var(--bs-gray-300); - background-color: var(--bs-white); + border-color: var(--bs-gray-300); + background-color: var(--bs-white); } .permission-level-section .form-select:focus { - border-color: var(--spectrumx-form-select-focus-color); - box-shadow: 0 0 0 0.25rem var(--spectrumx-form-select-focus-color); + border-color: var(--spectrumx-form-select-focus-color); + box-shadow: 0 0 0 0.25rem var(--spectrumx-form-select-focus-color); } .permission-level-section .form-text { - margin-top: 0.5rem; - font-size: 0.875rem; + margin-top: 0.5rem; + font-size: 0.875rem; } /* Permission level option styling */ .permission-level-section .form-select option { - padding: 0.5rem; + padding: 0.5rem; } /* Permission level selectors for existing users */ .permission-level-selector { - font-size: 0.875rem; - border: 1px solid var(--bs-gray-300); - border-radius: 0.375rem; - transition: all 0.2s ease; + font-size: 0.875rem; + border: 1px solid var(--bs-gray-300); + border-radius: 0.375rem; + transition: all 0.2s ease; } .permission-level-selector:focus { - border-color: var(--spectrumx-form-select-focus-color); - box-shadow: 0 0 0 0.25rem var(--spectrumx-form-select-focus-color); + border-color: var(--spectrumx-form-select-focus-color); + box-shadow: 0 0 0 0.25rem var(--spectrumx-form-select-focus-color); } .permission-level-selector.focused { - border-color: var(--spectrumx-form-select-focus-color); - background-color: var(--bs-gray-100); + border-color: var(--spectrumx-form-select-focus-color); + background-color: var(--bs-gray-100); } .permission-level-selector.updating { - opacity: 0.6; - cursor: not-allowed; + opacity: 0.6; + cursor: not-allowed; } /* Permission level selector styling */ .permission-level-selector.permission-viewer { - border-color: var(--spectrumx-viewer-border); - background-color: var(--spectrumx-viewer-bg); + border-color: var(--spectrumx-viewer-border); + background-color: var(--spectrumx-viewer-bg); } .permission-level-selector.permission-contributor { - border-color: var(--spectrumx-contributor-border); - background-color: var(--spectrumx-contributor-bg); + border-color: var(--spectrumx-contributor-border); + background-color: var(--spectrumx-contributor-bg); } .permission-level-selector.permission-co-owner { - border-color: var(--spectrumx-co-owner-border); - background-color: var(--spectrumx-co-owner-bg); + border-color: var(--spectrumx-co-owner-border); + background-color: var(--spectrumx-co-owner-bg); } .permission-level-selector.permission-multiple { - border-color: var(--spectrumx-co-owner-border); - background-color: var(--spectrumx-co-owner-bg); - color: var(--spectrumx-co-owner-color); + border-color: var(--spectrumx-co-owner-border); + background-color: var(--spectrumx-co-owner-bg); + color: var(--spectrumx-co-owner-color); } /* Group permission management */ .group-permission-selector { - font-weight: 600; - border-width: 2px; + font-weight: 600; + border-width: 2px; } .member-permission-selector { - font-size: 0.8rem; - margin-left: 1rem; + font-size: 0.8rem; + margin-left: 1rem; } /* Hover effects for permission badges */ .permission-badge:hover { - transform: translateY(-1px); - box-shadow: 0 2px 4px var(--shadow-black-light); - transition: all 0.2s ease; + transform: translateY(-1px); + box-shadow: 0 2px 4px var(--shadow-black-light); + transition: all 0.2s ease; } /* Responsive adjustments */ @media (max-width: 768px) { - .permission-level-section { - padding: 0.75rem; - } + .permission-level-section { + padding: 0.75rem; + } - .permission-badge { - font-size: 0.7rem; - padding: 0.2rem 0.4rem; - } + .permission-badge { + font-size: 0.7rem; + padding: 0.2rem 0.4rem; + } } /* Animation for permission level changes */ .permission-level-section .form-select { - transition: all 0.2s ease; + transition: all 0.2s ease; } .permission-level-section .form-select:focus { - transform: scale(1.02); + transform: scale(1.02); } /* Tooltip styles for permission level descriptions */ .permission-tooltip { - position: relative; - cursor: help; + position: relative; + cursor: help; } .permission-tooltip:hover::after { - content: attr(data-tooltip); - position: absolute; - bottom: 100%; - left: 50%; - transform: translateX(-50%); - background-color: var(--bs-gray-700); - color: white; - padding: 0.5rem; - border-radius: 0.25rem; - font-size: 0.75rem; - white-space: nowrap; - z-index: 1000; - margin-bottom: 0.25rem; + content: attr(data-tooltip); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background-color: var(--bs-gray-700); + color: white; + padding: 0.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + white-space: nowrap; + z-index: 1000; + margin-bottom: 0.25rem; } .permission-tooltip:hover::before { - content: ""; - position: absolute; - bottom: 100%; - left: 50%; - transform: translateX(-50%); - border: 0.25rem solid transparent; - border-top-color: var(--bs-gray-700); - margin-bottom: -0.25rem; + content: ""; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + border: 0.25rem solid transparent; + border-top-color: var(--bs-gray-700); + margin-bottom: -0.25rem; } /* Dropdown menu styles for permission changes */ .permission-change-btn { - transition: all 0.2s ease; - display: flex; - align-items: center; - justify-content: space-between; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: space-between; } .permission-change-btn:hover:not(:disabled) { - background-color: var(--bs-gray-100); - transform: translateX(2px); + background-color: var(--bs-gray-100); + transform: translateX(2px); } .permission-change-btn.active { - background-color: var(--spectrumx-viewer-bg); - color: var(--spectrumx-viewer-color); - font-weight: 500; + background-color: var(--spectrumx-viewer-bg); + color: var(--spectrumx-viewer-color); + font-weight: 500; } /* Current permission styling */ .permission-change-btn.current-permission { - background-color: var(--spectrumx-viewer-bg); - color: var(--spectrumx-viewer-color); - font-weight: 500; - cursor: not-allowed; - opacity: 0.8; + background-color: var(--spectrumx-viewer-bg); + color: var(--spectrumx-viewer-color); + font-weight: 500; + cursor: not-allowed; + opacity: 0.8; } .permission-change-btn.current-permission:hover { - background-color: var(--spectrumx-viewer-bg); - transform: none; + background-color: var(--spectrumx-viewer-bg); + transform: none; } .permission-change-btn:disabled { - cursor: not-allowed; - opacity: 0.6; + cursor: not-allowed; + opacity: 0.6; } .permission-change-btn:disabled:hover { - background-color: transparent; - transform: none; + background-color: transparent; + transform: none; } /* Ensure dropdown header text stays on one line */ .dropdown-item-text { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .bg-owner { - background-color: var(--spectrumx-owner-color); - color: white; + background-color: var(--spectrumx-owner-color); + color: white; } .bg-contributor { - background-color: var(--spectrumx-contributor-color); - color: white; + background-color: var(--spectrumx-contributor-color); + color: white; } .bg-co-owner { - background-color: var(--spectrumx-co-owner-color); - color: white; + background-color: var(--spectrumx-co-owner-color); + color: white; } .bg-viewer { - background-color: var(--spectrumx-viewer-color); - color: white; + background-color: var(--spectrumx-viewer-color); + color: white; } /* Badge animation for permission changes */ .permission-badge { - transition: all 0.3s ease; + transition: all 0.3s ease; } .permission-badge.updating { - opacity: 0.7; - transform: scale(0.95); + opacity: 0.7; + transform: scale(0.95); } /* Enhanced badge styles for the share modal */ .permission-badge.bg-info { - background-color: var(--spectrumx-viewer-bg) !important; - color: var(--spectrumx-viewer-color) !important; - border: 1px solid var(--spectrumx-viewer-border); + background-color: var(--spectrumx-viewer-bg) !important; + color: var(--spectrumx-viewer-color) !important; + border: 1px solid var(--spectrumx-viewer-border); } .permission-badge.bg-warning { - background-color: var(--spectrumx-co-owner-bg) !important; - color: var(--spectrumx-co-owner-color) !important; - border: 1px solid var(--spectrumx-co-owner-border); + background-color: var(--spectrumx-co-owner-bg) !important; + color: var(--spectrumx-co-owner-color) !important; + border: 1px solid var(--spectrumx-co-owner-border); } .permission-badge.bg-success { - background-color: var(--spectrumx-contributor-bg) !important; - color: var(--spectrumx-contributor-color) !important; - border: 1px solid var(--spectrumx-contributor-border); + background-color: var(--spectrumx-contributor-bg) !important; + color: var(--spectrumx-contributor-color) !important; + border: 1px solid var(--spectrumx-contributor-border); } .permission-badge.bg-secondary { - background-color: var(--bs-gray-100) !important; - color: var(--bs-gray-600) !important; - border: 1px solid var(--bs-gray-300); + background-color: var(--bs-gray-100) !important; + color: var(--bs-gray-600) !important; + border: 1px solid var(--bs-gray-300); } .access-level-dropdown { - min-width: 120px; - text-align: left; - border: 1px solid var(--bs-gray-300); - background-color: var(--bs-white); - color: var(--bs-gray-700); - font-size: 0.875rem; - padding: 0.5rem 0.75rem; - border-radius: 0.375rem; - transition: all 0.2s ease; - position: relative; - display: flex; - align-items: center; - justify-content: space-between; + min-width: 120px; + text-align: left; + border: 1px solid var(--bs-gray-300); + background-color: var(--bs-white); + color: var(--bs-gray-700); + font-size: 0.875rem; + padding: 0.5rem 0.75rem; + border-radius: 0.375rem; + transition: all 0.2s ease; + position: relative; + display: flex; + align-items: center; + justify-content: space-between; } .access-level-dropdown::after { - content: ""; - width: 0; - height: 0; - border-left: 4px solid transparent; - border-right: 4px solid transparent; - border-top: 4px solid var(--bs-gray-600); - margin-left: 0.5rem; - flex-shrink: 0; + content: ""; + width: 0; + height: 0; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 4px solid var(--bs-gray-600); + margin-left: 0.5rem; + flex-shrink: 0; } /* Permission level color coordination */ .access-level-dropdown[data-current-permission="viewer"] { - color: var(--spectrumx-viewer-color); - border-color: var(--spectrumx-viewer-border); + color: var(--spectrumx-viewer-color); + border-color: var(--spectrumx-viewer-border); } .access-level-dropdown[data-current-permission="viewer"]:hover { - background-color: var(--spectrumx-viewer-bg); - border-color: var(--spectrumx-viewer-border); + background-color: var(--spectrumx-viewer-bg); + border-color: var(--spectrumx-viewer-border); } .access-level-dropdown[data-current-permission="viewer"]:focus { - border-color: var(--spectrumx-viewer-border); - box-shadow: 0 0 0 2px rgba(29, 78, 216, 0.2); + border-color: var(--spectrumx-viewer-border); + box-shadow: 0 0 0 2px rgba(29, 78, 216, 0.2); } .access-level-dropdown[data-current-permission="viewer"]::after { - border-top-color: var(--spectrumx-viewer-color); + border-top-color: var(--spectrumx-viewer-color); } .access-level-dropdown[data-current-permission="contributor"] { - color: var(--spectrumx-contributor-color); - border-color: var(--spectrumx-contributor-border); + color: var(--spectrumx-contributor-color); + border-color: var(--spectrumx-contributor-border); } .access-level-dropdown[data-current-permission="contributor"]:hover { - background-color: var(--spectrumx-contributor-bg); - border-color: var(--spectrumx-contributor-border); + background-color: var(--spectrumx-contributor-bg); + border-color: var(--spectrumx-contributor-border); } .access-level-dropdown[data-current-permission="contributor"]:focus { - border-color: var(--spectrumx-contributor-border); - box-shadow: 0 0 0 2px rgba(5, 150, 105, 0.2); + border-color: var(--spectrumx-contributor-border); + box-shadow: 0 0 0 2px rgba(5, 150, 105, 0.2); } .access-level-dropdown[data-current-permission="contributor"]::after { - border-top-color: var(--spectrumx-contributor-color); + border-top-color: var(--spectrumx-contributor-color); } .access-level-dropdown[data-current-permission="co-owner"] { - color: var(--spectrumx-co-owner-color); - border-color: var(--spectrumx-co-owner-border); + color: var(--spectrumx-co-owner-color); + border-color: var(--spectrumx-co-owner-border); } .access-level-dropdown[data-current-permission="co-owner"]:hover { - background-color: var(--spectrumx-co-owner-bg); - border-color: var(--spectrumx-co-owner-border); + background-color: var(--spectrumx-co-owner-bg); + border-color: var(--spectrumx-co-owner-border); } .access-level-dropdown[data-current-permission="co-owner"]:focus { - border-color: var(--spectrumx-co-owner-border); - box-shadow: 0 0 0 2px var(--spectrumx-co-owner-border); + border-color: var(--spectrumx-co-owner-border); + box-shadow: 0 0 0 2px var(--spectrumx-co-owner-border); } .access-level-dropdown[data-current-permission="co-owner"]::after { - border-top-color: var(--spectrumx-co-owner-color); + border-top-color: var(--spectrumx-co-owner-color); } .access-level-menu { - min-width: 180px; - border: 1px solid var(--bs-gray-300); - border-radius: 0.5rem; - box-shadow: 0 2px 10px var(--shadow-black-light); - padding: 0.5rem 0; + min-width: 180px; + border: 1px solid var(--bs-gray-300); + border-radius: 0.5rem; + box-shadow: 0 2px 10px var(--shadow-black-light); + padding: 0.5rem 0; } .access-level-menu .dropdown-item { - padding: 0.5rem 1rem; - color: var(--bs-gray-700); - font-size: 0.875rem; - border: none; - background: none; - transition: background-color 0.15s ease; - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; + padding: 0.5rem 1rem; + color: var(--bs-gray-700); + font-size: 0.875rem; + border: none; + background: none; + transition: background-color 0.15s ease; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; } /* Permission level colors in dropdown menu */ .access-level-menu .permission-change-btn[data-permission-level="viewer"] { - color: var(--spectrumx-viewer-color); + color: var(--spectrumx-viewer-color); } .access-level-menu - .permission-change-btn[data-permission-level="viewer"]:hover { - background-color: var(--spectrumx-viewer-bg); - color: var(--spectrumx-viewer-hover-color); + .permission-change-btn[data-permission-level="viewer"]:hover { + background-color: var(--spectrumx-viewer-bg); + color: var(--spectrumx-viewer-hover-color); } .access-level-menu - .permission-change-btn[data-permission-level="viewer"].selected { - background-color: var(--spectrumx-viewer-bg); - color: var(--spectrumx-viewer-hover-color); - font-weight: 500; + .permission-change-btn[data-permission-level="viewer"].selected { + background-color: var(--spectrumx-viewer-bg); + color: var(--spectrumx-viewer-hover-color); + font-weight: 500; } .access-level-menu - .permission-change-btn[data-permission-level="viewer"].selected - .bi-check { - color: var(--spectrumx-viewer-hover-color); + .permission-change-btn[data-permission-level="viewer"].selected + .bi-check { + color: var(--spectrumx-viewer-hover-color); } .access-level-menu .permission-change-btn[data-permission-level="contributor"] { - color: var(--spectrumx-contributor-color); + color: var(--spectrumx-contributor-color); } .access-level-menu - .permission-change-btn[data-permission-level="contributor"]:hover { - background-color: var(--spectrumx-contributor-bg); - color: var(--spectrumx-contributor-hover-color); + .permission-change-btn[data-permission-level="contributor"]:hover { + background-color: var(--spectrumx-contributor-bg); + color: var(--spectrumx-contributor-hover-color); } .access-level-menu - .permission-change-btn[data-permission-level="contributor"].selected { - background-color: var(--spectrumx-contributor-bg); - color: var(--spectrumx-contributor-hover-color); - font-weight: 500; + .permission-change-btn[data-permission-level="contributor"].selected { + background-color: var(--spectrumx-contributor-bg); + color: var(--spectrumx-contributor-hover-color); + font-weight: 500; } .access-level-menu - .permission-change-btn[data-permission-level="contributor"].selected - .bi-check { - color: var(--spectrumx-contributor-hover-color); + .permission-change-btn[data-permission-level="contributor"].selected + .bi-check { + color: var(--spectrumx-contributor-hover-color); } .access-level-menu .permission-change-btn[data-permission-level="co-owner"] { - color: var(--spectrumx-co-owner-color); + color: var(--spectrumx-co-owner-color); } .access-level-menu - .permission-change-btn[data-permission-level="co-owner"]:hover { - background-color: var(--spectrumx-co-owner-bg); - color: var(--spectrumx-co-owner-hover-color); + .permission-change-btn[data-permission-level="co-owner"]:hover { + background-color: var(--spectrumx-co-owner-bg); + color: var(--spectrumx-co-owner-hover-color); } .access-level-menu - .permission-change-btn[data-permission-level="co-owner"].selected { - background-color: var(--spectrumx-co-owner-bg); - color: var(--spectrumx-co-owner-hover-color); - font-weight: 500; + .permission-change-btn[data-permission-level="co-owner"].selected { + background-color: var(--spectrumx-co-owner-bg); + color: var(--spectrumx-co-owner-hover-color); + font-weight: 500; } .access-level-menu - .permission-change-btn[data-permission-level="co-owner"].selected - .bi-check { - color: var(--spectrumx-co-owner-hover-color); + .permission-change-btn[data-permission-level="co-owner"].selected + .bi-check { + color: var(--spectrumx-co-owner-hover-color); } /* Remove access styling */ .access-level-menu .dropdown-item.text-danger { - color: var(--spectrumx-remove-color); + color: var(--spectrumx-remove-color); } .access-level-menu .dropdown-item.text-danger:hover { - background-color: var(--spectrumx-remove-bg); - color: var(--spectrumx-remove-hover-color); + background-color: var(--spectrumx-remove-bg); + color: var(--spectrumx-remove-hover-color); } .access-level-menu .dropdown-item.text-danger .bi-person-slash { - color: var(--spectrumx-remove-color); + color: var(--spectrumx-remove-color); } .access-level-menu .dropdown-item.text-danger:hover .bi-person-slash { - color: var(--spectrumx-remove-hover-color); + color: var(--spectrumx-remove-hover-color); } /* Remove access dropdown button styling */ .access-level-dropdown[data-current-permission="remove"] { - color: var(--spectrumx-remove-color); - border-color: var(--spectrumx-remove-border); + color: var(--spectrumx-remove-color); + border-color: var(--spectrumx-remove-border); } .access-level-dropdown[data-current-permission="remove"]:hover { - background-color: var(--spectrumx-remove-bg); - border-color: var(--spectrumx-remove-border); + background-color: var(--spectrumx-remove-bg); + border-color: var(--spectrumx-remove-border); } .access-level-dropdown[data-current-permission="remove"]:focus { - border-color: var(--spectrumx-remove-border); - box-shadow: 0 0 0 2px var(--spectrumx-remove-border); + border-color: var(--spectrumx-remove-border); + box-shadow: 0 0 0 2px var(--spectrumx-remove-border); } .access-level-dropdown[data-current-permission="remove"]::after { - border-top-color: var(--spectrumx-remove-color); + border-top-color: var(--spectrumx-remove-color); } .access-level-menu .dropdown-divider { - margin: 0.5rem 0; - border-color: var(--bs-gray-300); + margin: 0.5rem 0; + border-color: var(--bs-gray-300); } diff --git a/gateway/sds_gateway/static/css/spectrumx_theme.css b/gateway/sds_gateway/static/css/spectrumx_theme.css index 98468ea3d..46111f26a 100644 --- a/gateway/sds_gateway/static/css/spectrumx_theme.css +++ b/gateway/sds_gateway/static/css/spectrumx_theme.css @@ -3,124 +3,124 @@ /* Homepage-specific styles */ .logo { - height: 40px; + height: 40px; } #spectrumx-logo { - height: 40px; + height: 40px; } .system-name { - font-size: 1.2rem; - color: #495057; - font-weight: 300; + font-size: 1.2rem; + color: #495057; + font-weight: 300; } .navbar-nav .nav-link { - color: #212529; - font-weight: 300; - padding: 0.5rem 1rem; - transition: color 0.2s ease; + color: #212529; + font-weight: 300; + padding: 0.5rem 1rem; + transition: color 0.2s ease; } .navbar-nav .nav-link:hover { - color: #1a4a8a; + color: #1a4a8a; } .navbar-nav .nav-link.active { - color: #1a4a8a; + color: #1a4a8a; } #navbarUserDropdown { - font-family: monospace; - /* email addresses and usernames read better in monospace */ + font-family: monospace; + /* email addresses and usernames read better in monospace */ } .btn-primary, .btn-secondary, .btn-success, .btn-danger { - border-radius: 8px; - padding: 8px 16px; - font-weight: 300; + border-radius: 8px; + padding: 8px 16px; + font-weight: 300; } .btn-primary { - --bs-btn-bg: #005a9c; - --bs-btn-border-color: #005a9c; - --bs-btn-hover-bg: #004b80; - --bs-btn-hover-border-color: #004b80; - --bs-btn-active-bg: #004b80; - --bs-btn-active-border-color: #004b80; - --bs-btn-disabled-bg: #005a9c; - --bs-btn-disabled-border-color: #005a9c; - background-color: #005a9c; - border-color: #005a9c; + --bs-btn-bg: #005a9c; + --bs-btn-border-color: #005a9c; + --bs-btn-hover-bg: #004b80; + --bs-btn-hover-border-color: #004b80; + --bs-btn-active-bg: #004b80; + --bs-btn-active-border-color: #004b80; + --bs-btn-disabled-bg: #005a9c; + --bs-btn-disabled-border-color: #005a9c; + background-color: #005a9c; + border-color: #005a9c; } .btn-primary:hover { - background-color: #004b80; - border-color: #004b80; + background-color: #004b80; + border-color: #004b80; } .btn-primary:focus, .btn-primary.focus { - background-color: #004b80; - border-color: #004b80; - box-shadow: 0 0 0 0.2rem rgba(0, 90, 156, 0.5); + background-color: #004b80; + border-color: #004b80; + box-shadow: 0 0 0 0.2rem rgba(0, 90, 156, 0.5); } .btn-primary:not(:disabled):not(.disabled):active, .btn-primary:not(:disabled):not(.disabled).active { - background-color: #004b80; - border-color: #004b80; + background-color: #004b80; + border-color: #004b80; } /* Ensure secondary buttons also use SpectrumX styling */ .btn-secondary { - --bs-btn-bg: #6c757d; - --bs-btn-border-color: #6c757d; - --bs-btn-hover-bg: #5a6268; - --bs-btn-hover-border-color: #5a6268; - background-color: #6c757d; - border-color: #6c757d; + --bs-btn-bg: #6c757d; + --bs-btn-border-color: #6c757d; + --bs-btn-hover-bg: #5a6268; + --bs-btn-hover-border-color: #5a6268; + background-color: #6c757d; + border-color: #6c757d; } .btn-success { - --bs-btn-bg: #28a745; - --bs-btn-border-color: #28a745; - --bs-btn-hover-bg: #218838; - --bs-btn-hover-border-color: #218838; - background-color: #28a745; - border-color: #28a745; + --bs-btn-bg: #28a745; + --bs-btn-border-color: #28a745; + --bs-btn-hover-bg: #218838; + --bs-btn-hover-border-color: #218838; + background-color: #28a745; + border-color: #28a745; } h1 { - font-weight: 300; + font-weight: 300; } hr { - border-top: 1px solid #495057; + border-top: 1px solid #495057; } .navbar-brand .vr { - background-color: #ced4da; - opacity: 1; + background-color: #ced4da; + opacity: 1; } .site-footer { - background-color: #212529; - /* A common dark color */ + background-color: #212529; + /* A common dark color */ } .footer-text { - font-weight: 300; - color: #ced4da; + font-weight: 300; + color: #ced4da; } .copyright-text { - font-size: 0.9rem; - color: #ced4da; + font-size: 0.9rem; + color: #ced4da; } /* Accessibility: High-contrast focus outline */ @@ -129,636 +129,636 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { - outline: 3px solid #005a9c; - outline-offset: 2px; - box-shadow: none; + outline: 3px solid #005a9c; + outline-offset: 2px; + box-shadow: none; } /* Custom dropdown for icon-only buttons */ .btn-icon-dropdown.dropdown-toggle::after { - display: none; + display: none; } /* Ensure dropdown buttons are visible and properly styled */ .btn-icon-dropdown { - background-color: #f8f9fa; - border: 1px solid #dee2e6; - color: #6c757d; - padding: 0.25rem 0.5rem; - border-radius: 0.25rem; - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 32px; - min-height: 32px; + background-color: #f8f9fa; + border: 1px solid #dee2e6; + color: #6c757d; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + min-height: 32px; } .btn-icon-dropdown:hover { - background-color: #e9ecef; - border-color: #adb5bd; - color: #495057; + background-color: #e9ecef; + border-color: #adb5bd; + color: #495057; } .btn-icon-dropdown:focus { - background-color: #e9ecef; - border-color: #005a9c; - color: #495057; - box-shadow: 0 0 0 0.2rem rgba(0, 90, 156, 0.25); + background-color: #e9ecef; + border-color: #005a9c; + color: #495057; + box-shadow: 0 0 0 0.2rem rgba(0, 90, 156, 0.25); } .btn-icon-dropdown .bi { - font-size: 1rem; - line-height: 1; + font-size: 1rem; + line-height: 1; } /* Ensure dropdown menu positioning */ .dropdown-menu { - display: none; - position: absolute; - top: 100%; - left: 0; - background-color: #fff; - border: 1px solid #dee2e6; - border-radius: 0.375rem; - box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); - min-width: 10rem; - padding: 0.5rem 0; - margin: 0.125rem 0 0; + display: none; + position: absolute; + top: 100%; + left: 0; + background-color: #fff; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + min-width: 10rem; + padding: 0.5rem 0; + margin: 0.125rem 0 0; } /* Alternative approach for specific table containers */ #dynamic-table-container { - overflow: visible; + overflow: visible; } .dropdown-menu.show { - display: block; + display: block; } /* Make table rows clickable */ [data-bs-toggle="modal"] { - cursor: pointer; + cursor: pointer; } /* Custom nouislider styles */ .noUi-target { - background: #e9ecef; - border-radius: 4px; - border: none; - box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1); + background: #e9ecef; + border-radius: 4px; + border: none; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1); } .noUi-connect { - background: #005a9c; + background: #005a9c; } .noUi-handle { - width: 20px; - height: 20px; - border: none; - border-radius: 50%; - background: #005a9c; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - cursor: pointer; - top: -6px; - right: -10px; + width: 20px; + height: 20px; + border: none; + border-radius: 50%; + background: #005a9c; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + cursor: pointer; + top: -6px; + right: -10px; } .noUi-handle:before, .noUi-handle:after { - display: none; - content: none; + display: none; + content: none; } .noUi-handle:focus { - outline: none; - background: #004b80; - box-shadow: 0 0 0 3px rgba(0, 90, 156, 0.3); + outline: none; + background: #004b80; + box-shadow: 0 0 0 3px rgba(0, 90, 156, 0.3); } .noUi-handle:focus-visible { - outline: 3px solid #005a9c; - outline-offset: 2px; + outline: 3px solid #005a9c; + outline-offset: 2px; } .noUi-handle.noUi-active { - background: #004b80; - box-shadow: 0 2px 8px rgba(0, 90, 156, 0.3); + background: #004b80; + box-shadow: 0 2px 8px rgba(0, 90, 156, 0.3); } .noUi-horizontal { - height: 8px; - border-radius: 4px; + height: 8px; + border-radius: 4px; } .noUi-horizontal .noUi-handle { - width: 20px; - height: 20px; - right: -10px; - top: -6px; + width: 20px; + height: 20px; + right: -10px; + top: -6px; } /* File Browser */ .file-browser { - border: 1px solid #dee2e6; - border-radius: 8px; - padding: 20px; - background: #f8f9fa; - max-width: 600px; + border: 1px solid #dee2e6; + border-radius: 8px; + padding: 20px; + background: #f8f9fa; + max-width: 600px; } .file-browser-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 15px; - padding-bottom: 10px; - border-bottom: 1px solid #dee2e6; + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 1px solid #dee2e6; } .selection-info { - font-size: 0.9rem; - color: #6c757d; - font-weight: 400; + font-size: 0.9rem; + color: #6c757d; + font-weight: 400; } .file-checkbox, .folder-checkbox { - margin-right: 8px; - margin-left: 0; - cursor: pointer; + margin-right: 8px; + margin-left: 0; + cursor: pointer; } /* Accessible Checkbox Styles */ .form-check-input:checked { - background-color: #005a9c; - border-color: #005a9c; + background-color: #005a9c; + border-color: #005a9c; } .file-browser ul { - list-style: none; - padding-left: 20px; - margin: 0; + list-style: none; + padding-left: 20px; + margin: 0; } .file-browser li > ul { - display: none; + display: none; } .file-browser li { - margin: 4px 0; - position: relative; + margin: 4px 0; + position: relative; } .file-browser .bi { - margin-right: 8px; - color: #565656; - /* Darker gray for higher contrast */ + margin-right: 8px; + color: #565656; + /* Darker gray for higher contrast */ } .file-browser .bi-folder-fill, .file-browser .bi-folder2-open { - color: #005a9c; - /* Darker blue for higher contrast */ + color: #005a9c; + /* Darker blue for higher contrast */ } .file-browser span { - display: flex; - align-items: center; - justify-content: space-between; - padding: 4px 8px; - border-radius: 4px; - cursor: pointer; - transition: background-color 0.2s; - position: relative; - width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; + position: relative; + width: 100%; } .file-browser .item-content { - display: flex; - align-items: center; - flex: 1; + display: flex; + align-items: center; + flex: 1; } .file-browser span:hover { - background-color: #e9ecef; + background-color: #e9ecef; } .file-browser span:focus { - outline: 2px solid #005a9c; - outline-offset: 2px; + outline: 2px solid #005a9c; + outline-offset: 2px; } /* Action buttons */ .file-browser .action-buttons { - position: static; - visibility: hidden; - display: flex; - flex-direction: row; - gap: 4px; - background: white; - border: 1px solid #dee2e6; - border-radius: 4px; - padding: 4px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - z-index: 10; - margin-left: auto; - opacity: 0; - transition: opacity 0.2s ease; + position: static; + visibility: hidden; + display: flex; + flex-direction: row; + gap: 4px; + background: white; + border: 1px solid #dee2e6; + border-radius: 4px; + padding: 4px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + z-index: 10; + margin-left: auto; + opacity: 0; + transition: opacity 0.2s ease; } .file-browser span:hover .action-buttons, .file-browser .action-buttons:hover { - visibility: visible; - opacity: 1; + visibility: visible; + opacity: 1; } .file-browser .action-btn { - background: none; - border: none; - padding: 0; - border-radius: 3px; - cursor: pointer; - color: #6c757d; - font-size: 12px; - transition: all 0.2s; - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; + background: none; + border: none; + padding: 0; + border-radius: 3px; + cursor: pointer; + color: #6c757d; + font-size: 12px; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; } .file-browser .action-btn .bi { - margin: 0; - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; + margin: 0; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; } .file-browser .action-btn:hover { - background-color: #e9ecef; - color: #495057; + background-color: #e9ecef; + color: #495057; } .file-browser .action-btn.delete:hover { - background-color: #dc3545; - color: white; + background-color: #dc3545; + color: white; } /* Context menu */ .context-menu { - position: fixed; - background: white; - border: 1px solid #dee2e6; - border-radius: 6px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - padding: 8px 0; - z-index: 1000; - min-width: 160px; - display: none; + position: fixed; + background: white; + border: 1px solid #dee2e6; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + padding: 8px 0; + z-index: 1000; + min-width: 160px; + display: none; } .context-menu.show { - display: block; + display: block; } .context-menu-item { - padding: 8px 16px; - cursor: pointer; - display: flex; - align-items: center; - gap: 8px; - transition: background-color 0.2s; + padding: 8px 16px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: background-color 0.2s; } .context-menu-item:hover { - background-color: #f8f9fa; + background-color: #f8f9fa; } .context-menu-item.delete:hover { - background-color: #dc3545; - color: white; + background-color: #dc3545; + color: white; } .context-menu-separator { - height: 1px; - background-color: #dee2e6; - margin: 4px 0; + height: 1px; + background-color: #dee2e6; + margin: 4px 0; } /* CSS for folder expansion/collapse */ .file-browser [aria-expanded="true"] ~ ul { - display: block; + display: block; } /* Fixed layout for sortable tables */ .table-fixed-layout { - table-layout: fixed; - width: 100%; + table-layout: fixed; + width: 100%; } .table-fixed-layout th:nth-child(1), .table-fixed-layout td:nth-child(1) { - width: 5%; + width: 5%; } .table-fixed-layout th:nth-child(2), .table-fixed-layout td:nth-child(2) { - width: 45%; + width: 45%; } .table-fixed-layout th:nth-child(3), .table-fixed-layout td:nth-child(3) { - width: 25%; + width: 25%; } .table-fixed-layout th:nth-child(4), .table-fixed-layout td:nth-child(4) { - width: 15%; + width: 15%; } .table-fixed-layout th:nth-child(5), .table-fixed-layout td:nth-child(5) { - width: 10%; + width: 10%; } /* Accessible link color for tables */ .table-fixed-layout tbody a { - color: #005a9c; - font-weight: 300; - text-decoration: underline; + color: #005a9c; + font-weight: 300; + text-decoration: underline; } .table-fixed-layout tbody a:hover { - color: #004b80; + color: #004b80; } /* Accessible range slider thumb */ .form-range::-webkit-slider-thumb { - background-color: #005a9c; + background-color: #005a9c; } .form-range::-moz-range-thumb { - background-color: #005a9c; + background-color: #005a9c; } /* Search results count styling */ #results-count { - font-size: 0.9rem; - font-weight: 400; - color: #6c757d; + font-size: 0.9rem; + font-weight: 400; + color: #6c757d; } /* Sort icon styling */ .sort-icon { - margin-left: 0.25rem; - opacity: 0.3; - transition: opacity 0.2s ease; + margin-left: 0.25rem; + opacity: 0.3; + transition: opacity 0.2s ease; } .sort-icon.active { - opacity: 1; + opacity: 1; } th.sortable { - cursor: pointer; - -webkit-user-select: none; - user-select: none; + cursor: pointer; + -webkit-user-select: none; + user-select: none; } th.sortable:hover .sort-icon { - opacity: 0.7; + opacity: 0.7; } /* Accessible Pagination Styles */ .pagination { - --bs-pagination-color: #005a9c; - --bs-pagination-hover-color: #004b80; - --bs-pagination-active-bg: #005a9c; - --bs-pagination-active-border-color: #005a9c; - --bs-pagination-disabled-color: #6c757d; - --bs-pagination-disabled-bg: #fff; - --bs-pagination-disabled-border-color: #dee2e6; + --bs-pagination-color: #005a9c; + --bs-pagination-hover-color: #004b80; + --bs-pagination-active-bg: #005a9c; + --bs-pagination-active-border-color: #005a9c; + --bs-pagination-disabled-color: #6c757d; + --bs-pagination-disabled-bg: #fff; + --bs-pagination-disabled-border-color: #dee2e6; } /* More specific pagination overrides to ensure SpectrumX blue */ .pagination .page-link { - color: #005a9c; - border-color: #dee2e6; + color: #005a9c; + border-color: #dee2e6; } .pagination .page-link:hover { - color: #004b80; - background-color: #f8f9fa; - border-color: #005a9c; + color: #004b80; + background-color: #f8f9fa; + border-color: #005a9c; } .pagination .page-item.active .page-link { - background-color: #005a9c; - border-color: #005a9c; - color: #fff; + background-color: #005a9c; + border-color: #005a9c; + color: #fff; } .pagination .page-item.disabled .page-link { - color: #6c757d; - background-color: #fff; - border-color: #dee2e6; + color: #6c757d; + background-color: #fff; + border-color: #dee2e6; } /* Ensure pagination focus states use SpectrumX colors */ .pagination .page-link:focus { - color: #005a9c; - background-color: #f8f9fa; - border-color: #005a9c; - box-shadow: 0 0 0 0.2rem rgba(0, 90, 156, 0.25); + color: #005a9c; + background-color: #f8f9fa; + border-color: #005a9c; + box-shadow: 0 0 0 0.2rem rgba(0, 90, 156, 0.25); } #selectAllBtn { - background: none; - border: none; - color: #005a9c; - font-size: 1rem; - font-weight: 300; - padding: 0 4px; - margin: 0; - display: inline-flex; - align-items: center; - gap: 6px; - box-shadow: none; - outline: none; - transition: color 0.2s; - min-width: 160px; - white-space: nowrap; - border-radius: 8px; + background: none; + border: none; + color: #005a9c; + font-size: 1rem; + font-weight: 300; + padding: 0 4px; + margin: 0; + display: inline-flex; + align-items: center; + gap: 6px; + box-shadow: none; + outline: none; + transition: color 0.2s; + min-width: 160px; + white-space: nowrap; + border-radius: 8px; } #selectAllBtn:hover, #selectAllBtn:focus { - color: #003366; - background: none; - text-decoration: underline; + color: #003366; + background: none; + text-decoration: underline; } #selectAllBtn i { - font-size: 1.1em; - margin-right: 4px; - vertical-align: middle; + font-size: 1.1em; + margin-right: 4px; + vertical-align: middle; } /* Skip link for accessibility */ .skip-link { - position: absolute; - top: 6px; - left: 6px; - background: #005a9c; - color: white; - padding: 8px; - text-decoration: none; - border-radius: 4px; - z-index: 1000; - transition: opacity 0.3s; - display: none; + position: absolute; + top: 6px; + left: 6px; + background: #005a9c; + color: white; + padding: 8px; + text-decoration: none; + border-radius: 4px; + z-index: 1000; + transition: opacity 0.3s; + display: none; } .skip-link:focus { - display: block; - outline: 3px solid #005a9c; - outline-offset: 2px; + display: block; + outline: 3px solid #005a9c; + outline-offset: 2px; } /* ARIA live region for dynamic content */ .aria-live { - position: absolute; - left: -10000px; - width: 1px; - height: 1px; - overflow: hidden; + position: absolute; + left: -10000px; + width: 1px; + height: 1px; + overflow: hidden; } /* Outline button variants */ .btn-outline-primary { - --bs-btn-color: #005a9c; - --bs-btn-border-color: #005a9c; - --bs-btn-hover-color: #fff; - --bs-btn-hover-bg: #005a9c; - --bs-btn-hover-border-color: #005a9c; - --bs-btn-active-color: #fff; - --bs-btn-active-bg: #005a9c; - --bs-btn-active-border-color: #005a9c; - color: #005a9c; - border-color: #005a9c; + --bs-btn-color: #005a9c; + --bs-btn-border-color: #005a9c; + --bs-btn-hover-color: #fff; + --bs-btn-hover-bg: #005a9c; + --bs-btn-hover-border-color: #005a9c; + --bs-btn-active-color: #fff; + --bs-btn-active-bg: #005a9c; + --bs-btn-active-border-color: #005a9c; + color: #005a9c; + border-color: #005a9c; } .btn-outline-secondary { - --bs-btn-color: #6c757d; - --bs-btn-border-color: #6c757d; - --bs-btn-hover-color: #fff; - --bs-btn-hover-bg: #6c757d; - --bs-btn-hover-border-color: #6c757d; - color: #6c757d; - border-color: #6c757d; + --bs-btn-color: #6c757d; + --bs-btn-border-color: #6c757d; + --bs-btn-hover-color: #fff; + --bs-btn-hover-bg: #6c757d; + --bs-btn-hover-border-color: #6c757d; + color: #6c757d; + border-color: #6c757d; } /* Small button variants */ .btn-sm.btn-primary { - background-color: #005a9c; - border-color: #005a9c; + background-color: #005a9c; + border-color: #005a9c; } .btn-sm.btn-secondary { - background-color: #6c757d; - border-color: #6c757d; + background-color: #6c757d; + border-color: #6c757d; } /* Large button variants */ .btn-lg.btn-primary { - background-color: #005a9c; - border-color: #005a9c; + background-color: #005a9c; + border-color: #005a9c; } /* Button groups */ .btn-group .btn-primary { - background-color: #005a9c; - border-color: #005a9c; + background-color: #005a9c; + border-color: #005a9c; } .btn-group .btn-outline-primary { - color: #005a9c; - border-color: #005a9c; + color: #005a9c; + border-color: #005a9c; } /* Tab buttons (used in group_captures.html) */ .btn-group .btn-primary.active-tab { - background-color: #005a9c; - border-color: #005a9c; - color: #fff; + background-color: #005a9c; + border-color: #005a9c; + color: #fff; } .btn-group .btn-outline-primary.inactive-tab { - color: #005a9c; - border-color: #005a9c; - background-color: transparent; + color: #005a9c; + border-color: #005a9c; + background-color: transparent; } /* Form controls and inputs */ .form-control:focus { - border-color: #005a9c; - box-shadow: 0 0 0 0.2rem rgba(0, 90, 156, 0.25); + border-color: #005a9c; + box-shadow: 0 0 0 0.2rem rgba(0, 90, 156, 0.25); } .form-select:focus { - border-color: #005a9c; - box-shadow: 0 0 0 0.2rem rgba(0, 90, 156, 0.25); + border-color: #005a9c; + box-shadow: 0 0 0 0.2rem rgba(0, 90, 156, 0.25); } /* Captures table layout optimization */ .captures-table-fixed { - width: 100%; - table-layout: fixed; + width: 100%; + table-layout: fixed; } /* Allow natural text fitting like datasets table */ .captures-table-fixed th, .captures-table-fixed td { - white-space: normal; - word-wrap: break-word; + white-space: normal; + word-wrap: break-word; } /* Basic table styling */ .captures-table-fixed th { - padding: 8px 12px; - font-size: 14px; + padding: 8px 12px; + font-size: 14px; } .captures-table-fixed td { - padding: 8px 12px; - vertical-align: middle; + padding: 8px 12px; + vertical-align: middle; } /* Bootstrap badge overrides to use SpectrumX theme colors */ .badge.bg-primary { - background-color: #005a9c; - color: #fff; + background-color: #005a9c; + color: #fff; } .badge.bg-primary:hover { - background-color: #004b80; + background-color: #004b80; } /* Make permission badges match dropdown button size */ .badge.access-level-badge { - padding: 0.25rem 0.5rem; - font-size: 0.875rem; - font-weight: 400; - border-radius: 0.375rem; - display: inline-flex; - align-items: center; - gap: 0.25rem; - min-height: 32px; - line-height: 1.4; + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + font-weight: 400; + border-radius: 0.375rem; + display: inline-flex; + align-items: center; + gap: 0.25rem; + min-height: 32px; + line-height: 1.4; } /* Selected captures/files badges */ @@ -766,24 +766,24 @@ th.sortable:hover .sort-icon { .selected-files-count, .captures-count, .files-count { - background-color: #005a9c; - color: #fff; + background-color: #005a9c; + color: #fff; } /* Any other primary badges */ .bg-primary { - background-color: #005a9c; - color: #fff; + background-color: #005a9c; + color: #fff; } /* Styles moved from group_captures.html */ .btn-fixed-width { - width: 140px; + width: 140px; } /* Style the number inputs to match */ input[type="number"].form-control-sm { - text-align: center; + text-align: center; } /* Table link colors */ @@ -792,9 +792,9 @@ input[type="number"].form-control-sm { .table-fixed-layout tbody a, #captures-table tbody a, .capture-link { - color: #005a9c; - font-weight: 300; - text-decoration: underline; + color: #005a9c; + font-weight: 300; + text-decoration: underline; } .table tbody a:hover, @@ -802,24 +802,24 @@ input[type="number"].form-control-sm { .table-fixed-layout tbody a:hover, #captures-table tbody a:hover, .capture-link:hover { - color: #004b80; + color: #004b80; } /* Button text color overrides */ .btn-primary, .btn-sm.btn-primary, .action-buttons .btn-primary { - color: #fff; - background-color: #005a9c; - border-color: #005a9c; + color: #fff; + background-color: #005a9c; + border-color: #005a9c; } .btn-primary:hover, .btn-sm.btn-primary:hover, .action-buttons .btn-primary:hover { - color: #fff; - background-color: #004b80; - border-color: #004b80; + color: #fff; + background-color: #004b80; + border-color: #004b80; } .btn-primary:active, @@ -828,348 +828,348 @@ input[type="number"].form-control-sm { .btn-primary:focus, .btn-sm.btn-primary:focus, .action-buttons .btn-primary:focus { - color: #fff; - background-color: #004b80; - border-color: #004b80; + color: #fff; + background-color: #004b80; + border-color: #004b80; } /* Action button text color overrides */ .action-buttons .btn-primary, .action-buttons .btn-sm.btn-primary { - color: #fff; - background-color: #005a9c; - border-color: #005a9c; - display: inline-flex; - align-items: center; - gap: 0.25rem; + color: #fff; + background-color: #005a9c; + border-color: #005a9c; + display: inline-flex; + align-items: center; + gap: 0.25rem; } .action-buttons .btn-primary i, .action-buttons .btn-sm.btn-primary i { - color: #fff; - margin-right: 4px; + color: #fff; + margin-right: 4px; } .action-buttons .btn-primary:hover, .action-buttons .btn-sm.btn-primary:hover { - color: #fff; - background-color: #004b80; - border-color: #004b80; + color: #fff; + background-color: #004b80; + border-color: #004b80; } .action-buttons .btn { - display: inline-flex; - align-items: center; - gap: 0.25rem; - color: inherit; + display: inline-flex; + align-items: center; + gap: 0.25rem; + color: inherit; } /* Table row height optimization */ .table > :not(caption) > * > * { - padding: 6px 12px; + padding: 6px 12px; } .table tbody td { - vertical-align: middle; + vertical-align: middle; } /* Ensure buttons in table stay properly sized */ .table .btn-sm { - padding: 0.25rem 0.5rem; + padding: 0.25rem 0.5rem; } /* Dataset table layout optimization */ #dynamic-table-container .table { - table-layout: fixed; - width: 100%; + table-layout: fixed; + width: 100%; } #dynamic-table-container .table th, #dynamic-table-container .table td { - padding: 8px 12px; - line-height: 1.4; - vertical-align: middle; + padding: 8px 12px; + line-height: 1.4; + vertical-align: middle; } #dynamic-table-container .table td { - white-space: normal; - overflow: hidden; - text-overflow: ellipsis; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; } /* Ensure buttons in table stay properly sized */ #dynamic-table-container .table .btn-sm { - padding: 0.25rem 0.5rem; - margin: 0 2px; + padding: 0.25rem 0.5rem; + margin: 0 2px; } /* Action buttons container */ #dynamic-table-container .table .action-buttons { - display: flex; - gap: 0.5rem; - justify-content: flex-start; + display: flex; + gap: 0.5rem; + justify-content: flex-start; } /* Custom Modal Styles */ .custom-modal { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 1060; - display: none; - align-items: center; - justify-content: center; - padding: 1rem; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1060; + display: none; + align-items: center; + justify-content: center; + padding: 1rem; } .custom-modal[style*="display: block"] { - display: flex; + display: flex; } .custom-modal-backdrop { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.5); - z-index: 1; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 1; } .custom-modal-dialog { - position: relative; - z-index: 2; - width: 100%; - max-width: 500px; - max-height: calc(100vh - 2rem); - margin: auto; - display: flex; - flex-direction: column; + position: relative; + z-index: 2; + width: 100%; + max-width: 500px; + max-height: calc(100vh - 2rem); + margin: auto; + display: flex; + flex-direction: column; } .custom-modal-content { - background-color: #fff; - border-radius: 0.5rem; - box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); - border: 1px solid #dee2e6; - position: relative; - display: flex; - flex-direction: column; - width: 100%; - max-height: 100%; + background-color: #fff; + border-radius: 0.5rem; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + border: 1px solid #dee2e6; + position: relative; + display: flex; + flex-direction: column; + width: 100%; + max-height: 100%; } .custom-modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 1rem 1.5rem; - border-bottom: 1px solid #dee2e6; - border-top-left-radius: 0.5rem; - border-top-right-radius: 0.5rem; - flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.5rem; + border-bottom: 1px solid #dee2e6; + border-top-left-radius: 0.5rem; + border-top-right-radius: 0.5rem; + flex-shrink: 0; } .custom-modal-title { - margin: 0; - font-size: 1.25rem; - font-weight: 500; - color: #212529; + margin: 0; + font-size: 1.25rem; + font-weight: 500; + color: #212529; } .custom-modal-close { - background: none; - border: none; - font-size: 1.5rem; - cursor: pointer; - padding: 0.5rem; - color: #6c757d; - border-radius: 0.25rem; - line-height: 1; - display: flex; - align-items: center; - justify-content: center; - width: 2rem; - height: 2rem; + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + padding: 0.5rem; + color: #6c757d; + border-radius: 0.25rem; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; } .custom-modal-close:hover { - color: #000; - background-color: #f8f9fa; + color: #000; + background-color: #f8f9fa; } .custom-modal-body { - padding: 1.5rem; - flex-grow: 1; - overflow-y: auto; + padding: 1.5rem; + flex-grow: 1; + overflow-y: auto; } .custom-modal-footer { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 0.75rem; - padding: 1rem 1.5rem; - border-top: 1px solid #dee2e6; - border-bottom-left-radius: 0.5rem; - border-bottom-right-radius: 0.5rem; - flex-shrink: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.75rem; + padding: 1rem 1.5rem; + border-top: 1px solid #dee2e6; + border-bottom-left-radius: 0.5rem; + border-bottom-right-radius: 0.5rem; + flex-shrink: 0; } .custom-modal-footer .btn { - margin: 0; + margin: 0; } /* Share Modal Specific Styles */ #pending-changes-message { - font-size: 0.875rem; - color: #856404; - background-color: #fff3cd; - border: 1px solid #ffeaa7; - border-radius: 0.25rem; - padding: 0.5rem 0.75rem; - display: flex; - align-items: center; - gap: 0.5rem; + font-size: 0.875rem; + color: #856404; + background-color: #fff3cd; + border: 1px solid #ffeaa7; + border-radius: 0.25rem; + padding: 0.5rem 0.75rem; + display: flex; + align-items: center; + gap: 0.5rem; } #pending-changes-message i { - color: #f39c12; + color: #f39c12; } /* Disabled dropdown button styles */ .btn-icon-dropdown:disabled { - opacity: 0.6; - cursor: not-allowed; + opacity: 0.6; + cursor: not-allowed; } .btn-icon-dropdown.btn-outline-danger { - color: #dc3545; - border-color: #dc3545; - background-color: transparent; + color: #dc3545; + border-color: #dc3545; + background-color: transparent; } .btn-icon-dropdown.btn-outline-danger:hover { - color: #fff; - background-color: #dc3545; - border-color: #dc3545; + color: #fff; + background-color: #dc3545; + border-color: #dc3545; } /* Dropdown item text (read-only) */ .dropdown-item-text { - cursor: default; - color: #6c757d; - background-color: transparent; + cursor: default; + color: #6c757d; + background-color: transparent; } .dropdown-item-text:hover { - background-color: transparent; - color: #6c757d; + background-color: transparent; + color: #6c757d; } /* Responsive adjustments for smaller screens */ @media (max-width: 576px) { - .custom-modal { - padding: 0.5rem; - } + .custom-modal { + padding: 0.5rem; + } - .custom-modal-dialog { - max-width: 100%; - max-height: calc(100vh - 1rem); - } + .custom-modal-dialog { + max-width: 100%; + max-height: calc(100vh - 1rem); + } - .custom-modal-header, - .custom-modal-body, - .custom-modal-footer { - padding: 1rem; - } + .custom-modal-header, + .custom-modal-body, + .custom-modal-footer { + padding: 1rem; + } } .modal-body .input-group .form-control { - background-color: transparent; - border: 1px solid #dee2e6; - box-shadow: none; - padding: 0.375rem 0.75rem; - border-radius: 4px 0 0 4px; - border-right: none; + background-color: transparent; + border: 1px solid #dee2e6; + box-shadow: none; + padding: 0.375rem 0.75rem; + border-radius: 4px 0 0 4px; + border-right: none; } .modal-body .input-group .form-control:disabled { - background-color: transparent; - opacity: 1; - color: #212529; - border: 1px solid #dee2e6; - border-right: none; + background-color: transparent; + opacity: 1; + color: #212529; + border: 1px solid #dee2e6; + border-right: none; } .modal-body .input-group .form-control:focus { - background-color: #fff; - border: 1px solid #005a9c; - border-right: none; - box-shadow: 0 0 0 0.2rem rgba(0, 90, 156, 0.25); + background-color: #fff; + border: 1px solid #005a9c; + border-right: none; + box-shadow: 0 0 0 0.2rem rgba(0, 90, 156, 0.25); } .modal-body .input-group .edit-btn, .modal-body .input-group .save-btn, .modal-body .input-group .cancel-btn { - border: 1px solid #dee2e6; - background: transparent; - margin-left: 0; - padding: 0.375rem 0.75rem; - display: flex; - align-items: center; - justify-content: center; + border: 1px solid #dee2e6; + background: transparent; + margin-left: 0; + padding: 0.375rem 0.75rem; + display: flex; + align-items: center; + justify-content: center; } .modal-body .input-group .edit-btn { - border-radius: 0 4px 4px 0; - border-left: none; + border-radius: 0 4px 4px 0; + border-left: none; } .modal-body .input-group .cancel-btn { - border-radius: 0; - border-left: none; - color: #dc3545; - display: none; - /* Hide by default */ + border-radius: 0; + border-left: none; + color: #dc3545; + display: none; + /* Hide by default */ } .modal-body .input-group .cancel-btn.show { - display: flex; - /* Show when .show class is added */ + display: flex; + /* Show when .show class is added */ } .modal-body .input-group .cancel-btn:hover { - background-color: #dc3545; - border-color: #dc3545; - color: #fff; + background-color: #dc3545; + border-color: #dc3545; + color: #fff; } .modal-body .input-group .save-btn { - border-radius: 0 4px 4px 0; - /* Default state */ - border-left: none; - color: #005a9c; - display: none; - /* Hide by default */ + border-radius: 0 4px 4px 0; + /* Default state */ + border-left: none; + color: #005a9c; + display: none; + /* Hide by default */ } .modal-body .input-group .save-btn.show { - display: flex; - /* Show when .show class is added */ - border-radius: 0 4px 4px 0; - /* When it's the last visible button */ + display: flex; + /* Show when .show class is added */ + border-radius: 0 4px 4px 0; + /* When it's the last visible button */ } .modal-body .input-group .edit-btn:hover { - background-color: #f8f9fa; - border-color: #005a9c; + background-color: #f8f9fa; + border-color: #005a9c; } .modal-body .input-group .save-btn .spinner-border { - width: 1rem; - height: 1rem; - border-width: 0.15em; + width: 1rem; + height: 1rem; + border-width: 0.15em; } diff --git a/gateway/sds_gateway/static/css/user_api_key_table.css b/gateway/sds_gateway/static/css/user_api_key_table.css index 07d5330dd..b63f9d73d 100644 --- a/gateway/sds_gateway/static/css/user_api_key_table.css +++ b/gateway/sds_gateway/static/css/user_api_key_table.css @@ -1,304 +1,304 @@ /* Custom table styles for user_api_key.html */ #dynamic-table-container .user-api-key-table { - margin-bottom: 5rem; - width: 100%; - table-layout: fixed; /* Match dataset table approach */ + margin-bottom: 5rem; + width: 100%; + table-layout: fixed; /* Match dataset table approach */ } /* Override table-responsive overflow for API key table */ #dynamic-table-container .table-responsive { - overflow-x: hidden; /* Prevent horizontal scrolling by default */ - max-width: 100%; + overflow-x: hidden; /* Prevent horizontal scrolling by default */ + max-width: 100%; } /* Ensure the table container doesn't overflow */ #dynamic-table-container .table-container { - max-width: 100%; - overflow-x: hidden; + max-width: 100%; + overflow-x: hidden; } /* Match dataset table cell styling */ #dynamic-table-container .user-api-key-table th, #dynamic-table-container .user-api-key-table td { - padding: 8px 12px; - line-height: 1.4; - vertical-align: middle; + padding: 8px 12px; + line-height: 1.4; + vertical-align: middle; } #dynamic-table-container .user-api-key-table td { - white-space: normal; - overflow: hidden; - text-overflow: ellipsis; + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; } /* Exception for Actions column - allow dropdown to overflow */ #dynamic-table-container .user-api-key-table td:last-child { - overflow: visible; + overflow: visible; } /* Fix dropdown positioning for API key table - override the problematic fixed positioning */ #dynamic-table-container .user-api-key-table .dropdown-menu { - position: absolute; - top: 100%; - right: 0; - z-index: 1050; + position: absolute; + top: 100%; + right: 0; + z-index: 1050; } /* Ensure dropdown container has proper positioning context */ #dynamic-table-container .user-api-key-table .dropdown { - position: relative; + position: relative; } /* Ensure table cells allow dropdowns to overflow */ #dynamic-table-container .user-api-key-table td { - overflow: visible; - position: relative; + overflow: visible; + position: relative; } /* Specific fix for the action column */ #dynamic-table-container .user-api-key-table td.col-action { - overflow: visible; - position: relative; + overflow: visible; + position: relative; } /* Column width classes for more flexible styling */ #dynamic-table-container .user-api-key-table th.col-prefix, #dynamic-table-container .user-api-key-table td.col-prefix { - width: 15%; + width: 15%; } #dynamic-table-container .user-api-key-table th.col-name, #dynamic-table-container .user-api-key-table td.col-name { - width: 20%; + width: 20%; } #dynamic-table-container .user-api-key-table th.col-description, #dynamic-table-container .user-api-key-table td.col-description { - width: 30%; + width: 30%; } #dynamic-table-container .user-api-key-table th.col-created, #dynamic-table-container .user-api-key-table td.col-created { - width: 20%; + width: 20%; } #dynamic-table-container .user-api-key-table th.col-expires, #dynamic-table-container .user-api-key-table td.col-expires { - width: 20%; + width: 20%; } #dynamic-table-container .user-api-key-table th.col-revoked, #dynamic-table-container .user-api-key-table td.col-revoked { - width: 10%; + width: 10%; } #dynamic-table-container .user-api-key-table th.col-action, #dynamic-table-container .user-api-key-table td.col-action { - width: 5%; + width: 5%; } /* Column widths that add up to 100% - matching dataset table approach */ #dynamic-table-container .user-api-key-table th:nth-child(1), #dynamic-table-container .user-api-key-table td:nth-child(1) { - width: 12%; + width: 12%; } #dynamic-table-container .user-api-key-table th:nth-child(2), #dynamic-table-container .user-api-key-table td:nth-child(2) { - width: 18%; + width: 18%; } #dynamic-table-container .user-api-key-table th:nth-child(3), #dynamic-table-container .user-api-key-table td:nth-child(3) { - width: 25%; - white-space: normal; - word-wrap: break-word; + width: 25%; + white-space: normal; + word-wrap: break-word; } #dynamic-table-container .user-api-key-table th:nth-child(4), #dynamic-table-container .user-api-key-table td:nth-child(4) { - width: 15%; + width: 15%; } #dynamic-table-container .user-api-key-table th:nth-child(5), #dynamic-table-container .user-api-key-table td:nth-child(5) { - width: 15%; + width: 15%; } #dynamic-table-container .user-api-key-table th:nth-child(6), #dynamic-table-container .user-api-key-table td:nth-child(6) { - width: 8%; + width: 8%; } #dynamic-table-container .user-api-key-table th:nth-child(7), #dynamic-table-container .user-api-key-table td:nth-child(7) { - width: 7%; - min-width: 60px; + width: 7%; + min-width: 60px; } /* Ensure buttons in table stay properly sized */ #dynamic-table-container .user-api-key-table .btn-sm { - padding: 0.25rem 0.5rem; - margin: 0 2px; + padding: 0.25rem 0.5rem; + margin: 0 2px; } /* Responsive breakpoints for smaller screens */ @media (max-width: 768px) { - #dynamic-table-container .user-api-key-table th:nth-child(1), - #dynamic-table-container .user-api-key-table td:nth-child(1) { - width: 15%; - } - #dynamic-table-container .user-api-key-table th:nth-child(2), - #dynamic-table-container .user-api-key-table td:nth-child(2) { - width: 20%; - } - #dynamic-table-container .user-api-key-table th:nth-child(3), - #dynamic-table-container .user-api-key-table td:nth-child(3) { - width: 30%; - } - #dynamic-table-container .user-api-key-table th:nth-child(4), - #dynamic-table-container .user-api-key-table td:nth-child(4) { - width: 15%; - } - #dynamic-table-container .user-api-key-table th:nth-child(5), - #dynamic-table-container .user-api-key-table td:nth-child(5) { - width: 10%; - } - #dynamic-table-container .user-api-key-table th:nth-child(6), - #dynamic-table-container .user-api-key-table td:nth-child(6) { - width: 5%; - } - #dynamic-table-container .user-api-key-table th:nth-child(7), - #dynamic-table-container .user-api-key-table td:nth-child(7) { - width: 5%; - } + #dynamic-table-container .user-api-key-table th:nth-child(1), + #dynamic-table-container .user-api-key-table td:nth-child(1) { + width: 15%; + } + #dynamic-table-container .user-api-key-table th:nth-child(2), + #dynamic-table-container .user-api-key-table td:nth-child(2) { + width: 20%; + } + #dynamic-table-container .user-api-key-table th:nth-child(3), + #dynamic-table-container .user-api-key-table td:nth-child(3) { + width: 30%; + } + #dynamic-table-container .user-api-key-table th:nth-child(4), + #dynamic-table-container .user-api-key-table td:nth-child(4) { + width: 15%; + } + #dynamic-table-container .user-api-key-table th:nth-child(5), + #dynamic-table-container .user-api-key-table td:nth-child(5) { + width: 10%; + } + #dynamic-table-container .user-api-key-table th:nth-child(6), + #dynamic-table-container .user-api-key-table td:nth-child(6) { + width: 5%; + } + #dynamic-table-container .user-api-key-table th:nth-child(7), + #dynamic-table-container .user-api-key-table td:nth-child(7) { + width: 5%; + } } /* Mobile-first responsive design for very narrow screens */ @media (max-width: 576px) { - /* Enable horizontal scrolling on very narrow screens */ - #dynamic-table-container .table-responsive { - overflow-x: auto; /* Enable horizontal scroll */ - -webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */ - } - - /* Set minimum table width to ensure all columns are visible */ - #dynamic-table-container .user-api-key-table { - min-width: 600px; /* Minimum width to show all columns */ - width: 600px; /* Fixed width for mobile */ - } - - /* Adjust column widths for mobile - ensure all content is readable */ - #dynamic-table-container .user-api-key-table th:nth-child(1), - #dynamic-table-container .user-api-key-table td:nth-child(1) { - width: 80px; /* Fixed width for prefix */ - min-width: 80px; - } - - #dynamic-table-container .user-api-key-table th:nth-child(2), - #dynamic-table-container .user-api-key-table td:nth-child(2) { - width: 120px; /* Fixed width for name */ - min-width: 120px; - } - - #dynamic-table-container .user-api-key-table th:nth-child(3), - #dynamic-table-container .user-api-key-table td:nth-child(3) { - width: 150px; /* Fixed width for description */ - min-width: 150px; - } - - #dynamic-table-container .user-api-key-table th:nth-child(4), - #dynamic-table-container .user-api-key-table td:nth-child(4) { - width: 100px; /* Fixed width for created */ - min-width: 100px; - } - - #dynamic-table-container .user-api-key-table th:nth-child(5), - #dynamic-table-container .user-api-key-table td:nth-child(5) { - width: 100px; /* Fixed width for expires */ - min-width: 100px; - } - - #dynamic-table-container .user-api-key-table th:nth-child(6), - #dynamic-table-container .user-api-key-table td:nth-child(6) { - width: 50px; /* Fixed width for revoked */ - min-width: 50px; - } - - #dynamic-table-container .user-api-key-table th:nth-child(7), - #dynamic-table-container .user-api-key-table td:nth-child(7) { - width: 50px; /* Fixed width for actions */ - min-width: 50px; - } - - /* Ensure text doesn't wrap in mobile view for better readability */ - #dynamic-table-container .user-api-key-table td { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - /* Allow description to wrap for better mobile experience */ - #dynamic-table-container .user-api-key-table td:nth-child(3) { - white-space: normal; - word-wrap: break-word; - } - - /* Add visual indicator for horizontal scroll */ - #dynamic-table-container .table-responsive::after { - content: "← Scroll →"; - display: block; - text-align: center; - padding: 8px; - color: #6c757d; - font-size: 0.875rem; - background: #f8f9fa; - border-top: 1px solid #dee2e6; - } + /* Enable horizontal scrolling on very narrow screens */ + #dynamic-table-container .table-responsive { + overflow-x: auto; /* Enable horizontal scroll */ + -webkit-overflow-scrolling: touch; /* Smooth scrolling on iOS */ + } + + /* Set minimum table width to ensure all columns are visible */ + #dynamic-table-container .user-api-key-table { + min-width: 600px; /* Minimum width to show all columns */ + width: 600px; /* Fixed width for mobile */ + } + + /* Adjust column widths for mobile - ensure all content is readable */ + #dynamic-table-container .user-api-key-table th:nth-child(1), + #dynamic-table-container .user-api-key-table td:nth-child(1) { + width: 80px; /* Fixed width for prefix */ + min-width: 80px; + } + + #dynamic-table-container .user-api-key-table th:nth-child(2), + #dynamic-table-container .user-api-key-table td:nth-child(2) { + width: 120px; /* Fixed width for name */ + min-width: 120px; + } + + #dynamic-table-container .user-api-key-table th:nth-child(3), + #dynamic-table-container .user-api-key-table td:nth-child(3) { + width: 150px; /* Fixed width for description */ + min-width: 150px; + } + + #dynamic-table-container .user-api-key-table th:nth-child(4), + #dynamic-table-container .user-api-key-table td:nth-child(4) { + width: 100px; /* Fixed width for created */ + min-width: 100px; + } + + #dynamic-table-container .user-api-key-table th:nth-child(5), + #dynamic-table-container .user-api-key-table td:nth-child(5) { + width: 100px; /* Fixed width for expires */ + min-width: 100px; + } + + #dynamic-table-container .user-api-key-table th:nth-child(6), + #dynamic-table-container .user-api-key-table td:nth-child(6) { + width: 50px; /* Fixed width for revoked */ + min-width: 50px; + } + + #dynamic-table-container .user-api-key-table th:nth-child(7), + #dynamic-table-container .user-api-key-table td:nth-child(7) { + width: 50px; /* Fixed width for actions */ + min-width: 50px; + } + + /* Ensure text doesn't wrap in mobile view for better readability */ + #dynamic-table-container .user-api-key-table td { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + /* Allow description to wrap for better mobile experience */ + #dynamic-table-container .user-api-key-table td:nth-child(3) { + white-space: normal; + word-wrap: break-word; + } + + /* Add visual indicator for horizontal scroll */ + #dynamic-table-container .table-responsive::after { + content: "← Scroll →"; + display: block; + text-align: center; + padding: 8px; + color: #6c757d; + font-size: 0.875rem; + background: #f8f9fa; + border-top: 1px solid #dee2e6; + } } /* Extra small screens - further optimize */ @media (max-width: 480px) { - #dynamic-table-container .user-api-key-table { - min-width: 550px; - width: 550px; - } - - /* Slightly reduce column widths for very small screens */ - #dynamic-table-container .user-api-key-table th:nth-child(1), - #dynamic-table-container .user-api-key-table td:nth-child(1) { - width: 70px; - min-width: 70px; - } - - #dynamic-table-container .user-api-key-table th:nth-child(2), - #dynamic-table-container .user-api-key-table td:nth-child(2) { - width: 100px; - min-width: 100px; - } - - #dynamic-table-container .user-api-key-table th:nth-child(3), - #dynamic-table-container .user-api-key-table td:nth-child(3) { - width: 130px; - min-width: 130px; - } - - #dynamic-table-container .user-api-key-table th:nth-child(4), - #dynamic-table-container .user-api-key-table td:nth-child(4) { - width: 90px; - min-width: 90px; - } - - #dynamic-table-container .user-api-key-table th:nth-child(5), - #dynamic-table-container .user-api-key-table td:nth-child(5) { - width: 90px; - min-width: 90px; - } - - #dynamic-table-container .user-api-key-table th:nth-child(6), - #dynamic-table-container .user-api-key-table td:nth-child(6) { - width: 40px; - min-width: 40px; - } - - #dynamic-table-container .user-api-key-table th:nth-child(7), - #dynamic-table-container .user-api-key-table td:nth-child(7) { - width: 40px; - min-width: 40px; - } + #dynamic-table-container .user-api-key-table { + min-width: 550px; + width: 550px; + } + + /* Slightly reduce column widths for very small screens */ + #dynamic-table-container .user-api-key-table th:nth-child(1), + #dynamic-table-container .user-api-key-table td:nth-child(1) { + width: 70px; + min-width: 70px; + } + + #dynamic-table-container .user-api-key-table th:nth-child(2), + #dynamic-table-container .user-api-key-table td:nth-child(2) { + width: 100px; + min-width: 100px; + } + + #dynamic-table-container .user-api-key-table th:nth-child(3), + #dynamic-table-container .user-api-key-table td:nth-child(3) { + width: 130px; + min-width: 130px; + } + + #dynamic-table-container .user-api-key-table th:nth-child(4), + #dynamic-table-container .user-api-key-table td:nth-child(4) { + width: 90px; + min-width: 90px; + } + + #dynamic-table-container .user-api-key-table th:nth-child(5), + #dynamic-table-container .user-api-key-table td:nth-child(5) { + width: 90px; + min-width: 90px; + } + + #dynamic-table-container .user-api-key-table th:nth-child(6), + #dynamic-table-container .user-api-key-table td:nth-child(6) { + width: 40px; + min-width: 40px; + } + + #dynamic-table-container .user-api-key-table th:nth-child(7), + #dynamic-table-container .user-api-key-table td:nth-child(7) { + width: 40px; + min-width: 40px; + } } diff --git a/gateway/sds_gateway/static/css/variables.css b/gateway/sds_gateway/static/css/variables.css index 546efb00c..55fea8ff3 100644 --- a/gateway/sds_gateway/static/css/variables.css +++ b/gateway/sds_gateway/static/css/variables.css @@ -1,54 +1,54 @@ /* Colors and variables for new styles */ :root { - --primary-color: #002147; - --secondary-color: #0056b3; - --text-color: #333; - --light-gray: #f5f5f5; - --mint-green: #d6e9c6; - /* Note: --bs-white and --bs-black are provided by Bootstrap */ - --pink: #f2dede; - --dark-pink: #eed3d7; - --red: #b94a48; + --primary-color: #002147; + --secondary-color: #0056b3; + --text-color: #333; + --light-gray: #f5f5f5; + --mint-green: #d6e9c6; + /* Note: --bs-white and --bs-black are provided by Bootstrap */ + --pink: #f2dede; + --dark-pink: #eed3d7; + --red: #b94a48; - /* Permission Level Color Variables */ + /* Permission Level Color Variables */ - /* Viewer (Blue) */ - --spectrumx-viewer-color: #1d4ed8; - --spectrumx-viewer-hover-color: #1e40af; - --spectrumx-viewer-bg: #dbeafe; - --spectrumx-viewer-border: #1d4ed8; + /* Viewer (Blue) */ + --spectrumx-viewer-color: #1d4ed8; + --spectrumx-viewer-hover-color: #1e40af; + --spectrumx-viewer-bg: #dbeafe; + --spectrumx-viewer-border: #1d4ed8; - /* Contributor (Green) */ - --spectrumx-contributor-color: #059669; - --spectrumx-contributor-hover-color: #047857; - --spectrumx-contributor-bg: #d1fae5; - --spectrumx-contributor-border: #059669; + /* Contributor (Green) */ + --spectrumx-contributor-color: #059669; + --spectrumx-contributor-hover-color: #047857; + --spectrumx-contributor-bg: #d1fae5; + --spectrumx-contributor-border: #059669; - /* Co-Owner (Orange) */ - --spectrumx-co-owner-color: #d97706; - --spectrumx-co-owner-hover-color: #92400e; - --spectrumx-co-owner-bg: #fef3c7; - --spectrumx-co-owner-border: #d97706; + /* Co-Owner (Orange) */ + --spectrumx-co-owner-color: #d97706; + --spectrumx-co-owner-hover-color: #92400e; + --spectrumx-co-owner-bg: #fef3c7; + --spectrumx-co-owner-border: #d97706; - /* Owner (Purple) */ - --spectrumx-owner-color: #7924c9; - --spectrumx-owner-hover-color: #510d8c; - --spectrumx-owner-bg: #ddcfff; - --spectrumx-owner-border: #7924c9; + /* Owner (Purple) */ + --spectrumx-owner-color: #7924c9; + --spectrumx-owner-hover-color: #510d8c; + --spectrumx-owner-bg: #ddcfff; + --spectrumx-owner-border: #7924c9; - /* Remove/Danger (Red) */ - --spectrumx-remove-color: #dc2626; - --spectrumx-remove-hover-color: #b91c1c; - --spectrumx-remove-bg: #fef2f2; - --spectrumx-remove-border: #dc2626; + /* Remove/Danger (Red) */ + --spectrumx-remove-color: #dc2626; + --spectrumx-remove-hover-color: #b91c1c; + --spectrumx-remove-bg: #fef2f2; + --spectrumx-remove-border: #dc2626; - /* SpectrumX Form Colors */ - --spectrumx-form-select-focus-color: #86b7fe; + /* SpectrumX Form Colors */ + --spectrumx-form-select-focus-color: #86b7fe; - /* Homepage Colors */ - --home-institution-color: #0d2e46; + /* Homepage Colors */ + --home-institution-color: #0d2e46; - /* Note: Bootstrap 5 already provides these variables: + /* Note: Bootstrap 5 already provides these variables: * --bs-primary, --bs-secondary, --bs-success, --bs-danger, * --bs-warning, --bs-info, --bs-light, --bs-dark * --bs-gray-100 through --bs-gray-900 @@ -57,55 +57,55 @@ * https://getbootstrap.com/docs/5.3/customize/color/#colors */ - /* Custom extensions to Bootstrap variables */ - --bs-primary-hover: #0a58ca; - --bs-primary-dark: #004b80; - --bs-success-hover: #198754; - --bs-success-dark: #157347; - --bs-danger-hover: #dc3545; - --bs-danger-dark: #bb2d3b; - --bs-danger-light: #f8d7da; - --bs-info-bg: #d1ecf1; - --bs-info-dark: #0c5460; - --bs-warning-bg: #fff3cd; - --bs-warning-dark: #856404; - --bs-warning-light: #f9c97d; + /* Custom extensions to Bootstrap variables */ + --bs-primary-hover: #0a58ca; + --bs-primary-dark: #004b80; + --bs-success-hover: #198754; + --bs-success-dark: #157347; + --bs-danger-hover: #dc3545; + --bs-danger-dark: #bb2d3b; + --bs-danger-light: #f8d7da; + --bs-info-bg: #d1ecf1; + --bs-info-dark: #0c5460; + --bs-warning-bg: #fff3cd; + --bs-warning-dark: #856404; + --bs-warning-light: #f9c97d; - /* Google/Modern UI Colors */ - --google-blue: #4285f4; - --google-blue-shadow: rgba(66, 133, 244, 0.25); + /* Google/Modern UI Colors */ + --google-blue: #4285f4; + --google-blue-shadow: rgba(66, 133, 244, 0.25); - /* LFS Colors */ - --lfs-blue: #005a9c; - --lfs-yellow: var(--bs-warning); - --lfs-grey: var(--bs-gray-600); + /* LFS Colors */ + --lfs-blue: #005a9c; + --lfs-yellow: var(--bs-warning); + --lfs-grey: var(--bs-gray-600); - /* Gradient Colors */ - --gradient-1: #f9c97d; - --gradient-2: #f2a2a4; - --gradient-3: #e27bb1; - --gradient-4: #bf5ce1; - --gradient-5: #7f5ae1; - --gradient-6: #5ab9e6; - --gradient-7: #59d5e0; + /* Gradient Colors */ + --gradient-1: #f9c97d; + --gradient-2: #f2a2a4; + --gradient-3: #e27bb1; + --gradient-4: #bf5ce1; + --gradient-5: #7f5ae1; + --gradient-6: #5ab9e6; + --gradient-7: #59d5e0; - /* Additional UI Colors */ - --cyan: #0dcaf0; - --orange: #fd7e14; - --pink-magenta: #e83e8c; - --light-gray-text: var(--bs-gray-400); - --medium-gray-text: var(--bs-gray-500); - --red-text: #c00; - --medium-gray-border: #989a9d; - --shadow-black: rgba(0, 0, 0, 0.075); - --shadow-black-light: rgba(0, 0, 0, 0.1); - --shadow-black-medium: rgba(0, 0, 0, 0.15); - --shadow-black-very-light: rgba(0, 0, 0, 0.05); - --white-transparent: rgba(255, 255, 255, 0.1); + /* Additional UI Colors */ + --cyan: #0dcaf0; + --orange: #fd7e14; + --pink-magenta: #e83e8c; + --light-gray-text: var(--bs-gray-400); + --medium-gray-text: var(--bs-gray-500); + --red-text: #c00; + --medium-gray-border: #989a9d; + --shadow-black: rgba(0, 0, 0, 0.075); + --shadow-black-light: rgba(0, 0, 0, 0.1); + --shadow-black-medium: rgba(0, 0, 0, 0.15); + --shadow-black-very-light: rgba(0, 0, 0, 0.05); + --white-transparent: rgba(255, 255, 255, 0.1); } /* External link styling */ .external-link { - color: var(--lfs-blue); - text-decoration: underline; + color: var(--lfs-blue); + text-decoration: underline; } diff --git a/gateway/sds_gateway/static/css/visualizations/spectrogram.css b/gateway/sds_gateway/static/css/visualizations/spectrogram.css index 6899030ce..412b80981 100644 --- a/gateway/sds_gateway/static/css/visualizations/spectrogram.css +++ b/gateway/sds_gateway/static/css/visualizations/spectrogram.css @@ -1,143 +1,143 @@ /* Spectrogram Visualization Styles */ #spectrogramDisplay { - position: relative; - height: 500px; - background-color: #f8f9fa; - border-radius: 0.375rem; + position: relative; + height: 500px; + background-color: #f8f9fa; + border-radius: 0.375rem; } #spectrogramContainer { - position: relative; - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; } #spectrogramImage { - max-width: 100%; - max-height: 100%; - width: auto; - height: auto; - display: block; - border: 1px solid #dee2e6; - border-radius: 0.375rem; - object-fit: contain; + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + display: block; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + object-fit: contain; } .loading-overlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(255, 255, 255, 0.8); - display: flex; - align-items: center; - justify-content: center; - z-index: 10; - backdrop-filter: blur(1px); + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(255, 255, 255, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 10; + backdrop-filter: blur(1px); } /* Control Panel Styles */ .card { - box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); - border: 1px solid rgba(0, 0, 0, 0.125); + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + border: 1px solid rgba(0, 0, 0, 0.125); } .card-header { - background-color: #f8f9fa; - border-bottom: 1px solid rgba(0, 0, 0, 0.125); + background-color: #f8f9fa; + border-bottom: 1px solid rgba(0, 0, 0, 0.125); } .form-label { - font-weight: 500; - color: #495057; - margin-bottom: 0.5rem; + font-weight: 500; + color: #495057; + margin-bottom: 0.5rem; } .form-control, .form-select { - border: 1px solid #ced4da; - border-radius: 0.375rem; - transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + border: 1px solid #ced4da; + border-radius: 0.375rem; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; } .form-control:focus, .form-select:focus { - border-color: #86b7fe; - box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); + border-color: #86b7fe; + box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); } /* Button Styles */ .btn { - border-radius: 0.375rem; - font-weight: 500; - transition: all 0.15s ease-in-out; + border-radius: 0.375rem; + font-weight: 500; + transition: all 0.15s ease-in-out; } .btn-primary { - background-color: #0d6efd; - border-color: #0d6efd; + background-color: #0d6efd; + border-color: #0d6efd; } .btn-primary:hover { - background-color: #0b5ed7; - border-color: #0a58ca; + background-color: #0b5ed7; + border-color: #0a58ca; } .btn-outline-secondary { - color: #6c757d; - border-color: #6c757d; + color: #6c757d; + border-color: #6c757d; } .btn-outline-secondary:hover { - background-color: #6c757d; - border-color: #6c757d; - color: #fff; + background-color: #6c757d; + border-color: #6c757d; + color: #fff; } /* Responsive Design */ @media (max-width: 768px) { - .col-md-3.col-lg-2 { - margin-bottom: 1rem; - } + .col-md-3.col-lg-2 { + margin-bottom: 1rem; + } - #spectrogramDisplay { - height: 400px; - } + #spectrogramDisplay { + height: 400px; + } } @media (max-width: 576px) { - #spectrogramDisplay { - height: 300px; - } + #spectrogramDisplay { + height: 300px; + } } /* Loading States */ .btn:disabled { - opacity: 0.65; - cursor: not-allowed; + opacity: 0.65; + cursor: not-allowed; } /* Error States */ .error-message { - color: #dc3545; - background-color: #f8d7da; - border: 1px solid #f5c6cb; - border-radius: 0.375rem; - padding: 0.75rem; - margin: 1rem 0; + color: #dc3545; + background-color: #f8d7da; + border: 1px solid #f5c6cb; + border-radius: 0.375rem; + padding: 0.75rem; + margin: 1rem 0; } .success-message { - color: #198754; - background-color: #d1e7dd; - border: 1px solid #badbcc; - border-radius: 0.375rem; - padding: 0.75rem; - margin: 1rem 0; + color: #198754; + background-color: #d1e7dd; + border: 1px solid #badbcc; + border-radius: 0.375rem; + padding: 0.75rem; + margin: 1rem 0; } diff --git a/gateway/sds_gateway/static/css/visualizations/visualization_modal.css b/gateway/sds_gateway/static/css/visualizations/visualization_modal.css index 2b4f3626b..d3c4fa2cf 100644 --- a/gateway/sds_gateway/static/css/visualizations/visualization_modal.css +++ b/gateway/sds_gateway/static/css/visualizations/visualization_modal.css @@ -1,54 +1,54 @@ /* Visualization Modal Styles */ .visualization-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 1rem; - justify-items: center; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1rem; + justify-items: center; } .visualization-option { - display: block; - width: 100%; - max-width: 400px; + display: block; + width: 100%; + max-width: 400px; } .visualization-option .bi { - font-size: 3rem; + font-size: 3rem; } .visualization-option .card-footer { - border-top: none; + border-top: none; } .visualization-option .visualization-select-btn { - transition: all 0.2s ease-in-out; - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; + transition: all 0.2s ease-in-out; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; } .visualization-option .visualization-select-btn:hover { - transform: translateX(2px); + transform: translateX(2px); } /* Responsive adjustments */ @media (max-width: 768px) { - .visualization-grid { - grid-template-columns: 1fr; - gap: 0.75rem; - } - - .visualization-option .bi { - font-size: 2.5rem; - } - - .visualization-option .card-title { - font-size: 1.1rem; - } - - .visualization-option .card-text { - font-size: 0.9rem; - } + .visualization-grid { + grid-template-columns: 1fr; + gap: 0.75rem; + } + + .visualization-option .bi { + font-size: 2.5rem; + } + + .visualization-option .card-title { + font-size: 1.1rem; + } + + .visualization-option .card-text { + font-size: 0.9rem; + } } diff --git a/gateway/sds_gateway/static/css/visualizations/visualizations.css b/gateway/sds_gateway/static/css/visualizations/visualizations.css index e05529888..3f513449a 100644 --- a/gateway/sds_gateway/static/css/visualizations/visualizations.css +++ b/gateway/sds_gateway/static/css/visualizations/visualizations.css @@ -3,33 +3,33 @@ */ .visualization-error-display { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - z-index: 5; - max-width: 100%; - padding: 20px; - box-sizing: border-box; - overflow: auto; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 5; + max-width: 100%; + padding: 20px; + box-sizing: border-box; + overflow: auto; } .visualization-error-display p { - margin: 0; - font-size: 1.1rem; + margin: 0; + font-size: 1.1rem; } .visualization-error-display > * { - max-width: 100%; + max-width: 100%; } .error-message-text { - color: #dc3545; - font-weight: 500; - white-space: pre-wrap; + color: #dc3545; + font-weight: 500; + white-space: pre-wrap; } diff --git a/gateway/sds_gateway/static/css/visualizations/waterfall.css b/gateway/sds_gateway/static/css/visualizations/waterfall.css index 0fedf7db0..9c01667e4 100644 --- a/gateway/sds_gateway/static/css/visualizations/waterfall.css +++ b/gateway/sds_gateway/static/css/visualizations/waterfall.css @@ -1,332 +1,332 @@ /* Waterfall Visualization Styles */ .loading-overlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(255, 255, 255, 0.9); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - backdrop-filter: blur(2px); + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(255, 255, 255, 0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(2px); } #waterfallCanvas { - border: none; - position: relative; + border: none; + position: relative; } .control-panel { - position: sticky; - top: 1rem; + position: sticky; + top: 1rem; } /* Custom range slider styling */ .form-range::-webkit-slider-thumb { - background: #0d6efd; - border-radius: 50%; - cursor: pointer; + background: #0d6efd; + border-radius: 50%; + cursor: pointer; } .form-range::-moz-range-thumb { - background: #0d6efd; - border-radius: 50%; - cursor: pointer; + background: #0d6efd; + border-radius: 50%; + cursor: pointer; } /* Chart container styling */ #periodogramChart { - min-height: 200px; - height: 200px; - /* Add padding to align with waterfall plot margins */ - padding-right: var(--plots-right-margin, 80px); - padding-left: var(--plots-left-margin, 85px); + min-height: 200px; + height: 200px; + /* Add padding to align with waterfall plot margins */ + padding-right: var(--plots-right-margin, 80px); + padding-left: var(--plots-left-margin, 85px); } #waterfallPlot { - position: relative; - height: 400px; - /* Ensure proper alignment with periodogram */ - margin-left: 0; - margin-right: 0; + position: relative; + height: 400px; + /* Ensure proper alignment with periodogram */ + margin-left: 0; + margin-right: 0; } #waterfallOverlayCanvas { - position: absolute; - top: 0; - left: 0; - pointer-events: none; - z-index: 1; + position: absolute; + top: 0; + left: 0; + pointer-events: none; + z-index: 1; } /* Button states */ .btn:disabled { - opacity: 0.6; - cursor: not-allowed; + opacity: 0.6; + cursor: not-allowed; } /* Loading states */ .loading { - opacity: 0.6; - pointer-events: none; + opacity: 0.6; + pointer-events: none; } /* Responsive adjustments */ @media (max-width: 768px) { - .col-md-3.col-lg-2 { - margin-bottom: 1rem; - } + .col-md-3.col-lg-2 { + margin-bottom: 1rem; + } - #waterfallCanvas { - width: 100% !important; - height: auto !important; - } + #waterfallCanvas { + width: 100% !important; + height: auto !important; + } } /* Custom scrollbar for controls */ .card-body::-webkit-scrollbar { - width: 6px; + width: 6px; } .card-body::-webkit-scrollbar-track { - background: #f1f1f1; - border-radius: 3px; + background: #f1f1f1; + border-radius: 3px; } .card-body::-webkit-scrollbar-thumb { - background: #c1c1c1; - border-radius: 3px; + background: #c1c1c1; + border-radius: 3px; } .card-body::-webkit-scrollbar-thumb:hover { - background: #a8a8a8; + background: #a8a8a8; } /* Animation for loading states */ @keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } + from { + opacity: 0; + } + to { + opacity: 1; + } } .fade-in { - animation: fadeIn 0.3s ease-in; + animation: fadeIn 0.3s ease-in; } /* Tooltip styling */ .tooltip { - position: absolute; - background: rgba(0, 0, 0, 0.8); - color: white; - padding: 0.5rem; - border-radius: 0.25rem; - font-size: 0.875rem; - pointer-events: none; - z-index: 1001; + position: absolute; + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 0.5rem; + border-radius: 0.25rem; + font-size: 0.875rem; + pointer-events: none; + z-index: 1001; } /* Frequency and time axis labels */ .axis-label { - font-size: 0.75rem; - fill: #6c757d; - text-anchor: middle; + font-size: 0.75rem; + fill: #6c757d; + text-anchor: middle; } /* Grid lines */ .grid-line { - stroke: #e9ecef; - stroke-width: 0.5; - opacity: 0.5; + stroke: #e9ecef; + stroke-width: 0.5; + opacity: 0.5; } /* Waterfall color scale legend */ .color-legend { - position: absolute; - top: 5px; - bottom: 5px; - right: 10px; - background: rgba(255, 255, 255, 0.95); - border: 1px solid #dee2e6; - border-radius: 0.25rem; - padding: 0.5rem; - font-size: 0.75rem; - max-width: var(--plots-right-margin, 80px); - z-index: 10; - display: flex; - flex-direction: row; - align-items: stretch; + position: absolute; + top: 5px; + bottom: 5px; + right: 10px; + background: rgba(255, 255, 255, 0.95); + border: 1px solid #dee2e6; + border-radius: 0.25rem; + padding: 0.5rem; + font-size: 0.75rem; + max-width: var(--plots-right-margin, 80px); + z-index: 10; + display: flex; + flex-direction: row; + align-items: stretch; } .legend-gradient { - width: 20px; - height: calc(100% - 40px); - margin: 10px 0; - border: 1px solid #dee2e6; - border-radius: 2px; - flex-shrink: 0; + width: 20px; + height: calc(100% - 40px); + margin: 10px 0; + border: 1px solid #dee2e6; + border-radius: 2px; + flex-shrink: 0; } .legend-labels { - display: flex; - flex-direction: column; - justify-content: space-between; - height: calc(100% - 40px); - font-size: 0.7rem; - color: #000; - text-align: left; - margin-left: 8px; - flex-grow: 1; + display: flex; + flex-direction: column; + justify-content: space-between; + height: calc(100% - 40px); + font-size: 0.7rem; + color: #000; + text-align: left; + margin-left: 8px; + flex-grow: 1; } /* Scroll indicators for waterfall plot */ .scroll-indicator { - width: 0; - height: 0; - cursor: pointer; - margin: 0 auto; - display: block; - transition: all 0.2s ease; - border-radius: 2px; + width: 0; + height: 0; + cursor: pointer; + margin: 0 auto; + display: block; + transition: all 0.2s ease; + border-radius: 2px; } .scroll-indicator:hover { - opacity: 0.8; - transform: scale(1.1); + opacity: 0.8; + transform: scale(1.1); } .scroll-indicator.up { - border-left: 15px solid transparent; - border-right: 15px solid transparent; - border-bottom: 15px solid #808080; + border-left: 15px solid transparent; + border-right: 15px solid transparent; + border-bottom: 15px solid #808080; } .scroll-indicator.down { - border-left: 15px solid transparent; - border-right: 15px solid transparent; - border-top: 15px solid #808080; + border-left: 15px solid transparent; + border-right: 15px solid transparent; + border-top: 15px solid #808080; } .scroll-indicator.disabled { - opacity: 0.3; - cursor: not-allowed; + opacity: 0.3; + cursor: not-allowed; } .scroll-indicator.disabled:hover { - opacity: 0.3; - transform: none; + opacity: 0.3; + transform: none; } /* Responsive scroll indicators */ @media (max-width: 768px) { - .scroll-indicator.up, - .scroll-indicator.down { - border-left-width: 12px; - border-right-width: 12px; - border-bottom-width: 12px; - border-top-width: 12px; - } + .scroll-indicator.up, + .scroll-indicator.down { + border-left-width: 12px; + border-right-width: 12px; + border-bottom-width: 12px; + border-top-width: 12px; + } } /* Scroll indicator container states */ .scroll-indicator-container { - visibility: hidden; - height: 20px; - margin: 0.5rem 0; + visibility: hidden; + height: 20px; + margin: 0.5rem 0; } .scroll-indicator-container.visible { - visibility: visible; + visibility: visible; } /* Slice index input styling */ #sliceIndexInput { - text-align: center; - font-weight: 500; - border: 1px solid #dee2e6; + text-align: center; + font-weight: 500; + border: 1px solid #dee2e6; } #sliceIndexInput:focus { - border-color: #0d6efd; - box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); + border-color: #0d6efd; + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); } /* Hide the default increment/decrement arrows */ #sliceIndexInput::-webkit-inner-spin-button, #sliceIndexInput::-webkit-outer-spin-button { - -webkit-appearance: none; - margin: 0; + -webkit-appearance: none; + margin: 0; } #sliceIndexInput[type="number"] { - appearance: textfield; - -moz-appearance: textfield; + appearance: textfield; + -moz-appearance: textfield; } /* Center the slice controls */ .slice-controls { - display: flex; - align-items: center; - justify-content: center; - flex-wrap: nowrap; - width: 100%; + display: flex; + align-items: center; + justify-content: center; + flex-wrap: nowrap; + width: 100%; } .slice-controls .btn { - flex-shrink: 0; - min-width: auto; - padding-left: 0.5rem; - padding-right: 0.5rem; + flex-shrink: 0; + min-width: auto; + padding-left: 0.5rem; + padding-right: 0.5rem; } .slice-controls .btn:hover { - background-color: #0d6efd; - border-color: #0d6efd !important; - color: #fff !important; + background-color: #0d6efd; + border-color: #0d6efd !important; + color: #fff !important; } .slice-controls .btn:first-child { - border-top-right-radius: 0; - border-bottom-right-radius: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; } .slice-controls .btn:last-child { - border-top-left-radius: 0; - border-bottom-left-radius: 0; + border-top-left-radius: 0; + border-bottom-left-radius: 0; } .slice-controls .btn i { - font-size: 0.875rem; + font-size: 0.875rem; } .slice-controls .mx-2 { - flex-shrink: 0; + flex-shrink: 0; } /* Ensure the input field doesn't take up too much space */ .slice-controls #sliceIndexInput { - width: 70px; - min-width: 70px; - border-radius: 0; + width: 70px; + min-width: 70px; + border-radius: 0; } /* Responsive canvas sizing */ @media (max-width: 1200px) { - #waterfallCanvas { - width: 100% !important; - height: auto !important; - } + #waterfallCanvas { + width: 100% !important; + height: auto !important; + } } /* Print styles */ @media print { - .btn-group, - .control-panel { - display: none !important; - } + .btn-group, + .control-panel { + display: none !important; + } } diff --git a/gateway/sds_gateway/static/js/__tests__/helpers/actionTestMocks.js b/gateway/sds_gateway/static/js/__tests__/helpers/actionTestMocks.js index bf3a94fbb..90395021a 100644 --- a/gateway/sds_gateway/static/js/__tests__/helpers/actionTestMocks.js +++ b/gateway/sds_gateway/static/js/__tests__/helpers/actionTestMocks.js @@ -3,29 +3,29 @@ */ const { - setupStandardUnitTest, - createMockWebDownloadButton, - createMockWebDownloadModal, - installWebDownloadDomMocks, -} = require("../../tests-config/testHelpers.js"); + setupStandardUnitTest, + createMockWebDownloadButton, + createMockWebDownloadModal, + installWebDownloadDomMocks, +} = require("../../tests-config/testHelpers.js") function installBootstrapModalMocks() { - global.bootstrap = { - Modal: jest.fn().mockImplementation(() => ({ - show: jest.fn(), - hide: jest.fn(), - })), - }; - global.bootstrap.Modal.getInstance = jest.fn(() => ({ - hide: jest.fn(), - })); + global.bootstrap = { + Modal: jest.fn().mockImplementation(() => ({ + show: jest.fn(), + hide: jest.fn(), + })), + } + global.bootstrap.Modal.getInstance = jest.fn(() => ({ + hide: jest.fn(), + })) } function createMockDownloadPermissions(overrides = {}) { - return { - canDownload: jest.fn(() => true), - ...overrides, - }; + return { + canDownload: jest.fn(() => true), + ...overrides, + } } /** @@ -34,192 +34,203 @@ function createMockDownloadPermissions(overrides = {}) { * @param {Record} [opts.buttonAttributes] */ function setupDownloadActionTestEnvironment(opts = {}) { - const mockPermissions = createMockDownloadPermissions(opts.permissions); - setupStandardUnitTest({ - useModalDomUtils: true, - apiClientOverrides: { - post: jest.fn().mockResolvedValue({ - success: true, - message: "Download request submitted successfully!", - }), - ...opts.apiClientOverrides, - }, - window: { - fetch: jest.fn(() => - Promise.resolve({ - ok: true, - json: () => - Promise.resolve({ - success: true, - message: "Download requested", - }), - }), - ), - showMessage: jest.fn().mockResolvedValue(true), - ...opts.window, - }, - }); - const mockButton = createMockWebDownloadButton({ - "data-item-uuid": "test-dataset-uuid", - "data-item-type": "dataset", - ...opts.buttonAttributes, - }); - const mockModal = createMockWebDownloadModal(); - installWebDownloadDomMocks(mockButton, mockModal); - installBootstrapModalMocks(); - return { mockPermissions, mockButton, mockModal }; + const mockPermissions = createMockDownloadPermissions(opts.permissions) + setupStandardUnitTest({ + useModalDomUtils: true, + apiClientOverrides: { + post: jest.fn().mockResolvedValue({ + success: true, + message: "Download request submitted successfully!", + }), + ...opts.apiClientOverrides, + }, + window: { + fetch: jest.fn(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + success: true, + message: "Download requested", + }), + }), + ), + showMessage: jest.fn().mockResolvedValue(true), + ...opts.window, + }, + }) + const mockButton = createMockWebDownloadButton({ + "data-item-uuid": "test-dataset-uuid", + "data-item-type": "dataset", + ...opts.buttonAttributes, + }) + const mockModal = createMockWebDownloadModal() + installWebDownloadDomMocks(mockButton, mockModal) + installBootstrapModalMocks() + return { mockPermissions, mockButton, mockModal } } function createDefaultShareActionConfig(overrides = {}) { - return { - itemUuid: "test-uuid", - itemType: "dataset", - permissions: { - canShare: true, - }, - ...overrides, - }; + return { + itemUuid: "test-uuid", + itemType: "dataset", + permissions: { + canShare: true, + }, + ...overrides, + } } function setupShareActionStandardTest(apiClientOverrides = {}) { - setupStandardUnitTest({ - useModalDomUtils: true, - apiClientOverrides: { - get: jest.fn().mockResolvedValue([]), - ...apiClientOverrides, - }, - }); + setupStandardUnitTest({ + useModalDomUtils: true, + apiClientOverrides: { + get: jest.fn().mockResolvedValue([]), + ...apiClientOverrides, + }, + }) } function createShareSearchTestContext(ShareActionManager, overrides = {}) { - const mockAPIClient = { - get: jest.fn(), - post: jest.fn(), - ...overrides.apiClient, - }; - global.window.APIClient = mockAPIClient; - const mockDropdown = { - querySelector: jest.fn(() => ({ - innerHTML: "", - })), - }; - const shareManager = new ShareActionManager({ - itemUuid: "test-uuid", - itemType: "dataset", - permissions: {}, - ...overrides.managerConfig, - }); - shareManager.displayResults = jest.fn(); - shareManager.displayError = jest.fn(); - shareManager.showDropdown = jest.fn(); - return { mockAPIClient, mockDropdown, shareManager }; + const mockAPIClient = { + get: jest.fn(), + post: jest.fn(), + ...overrides.apiClient, + } + global.window.APIClient = mockAPIClient + const mockDropdown = { + querySelector: jest.fn(() => ({ + innerHTML: "", + })), + } + const shareManager = new ShareActionManager({ + itemUuid: "test-uuid", + itemType: "dataset", + permissions: {}, + ...overrides.managerConfig, + }) + shareManager.displayResults = jest.fn() + shareManager.displayError = jest.fn() + shareManager.showDropdown = jest.fn() + return { mockAPIClient, mockDropdown, shareManager } } -function createMockVersionCreateButton(datasetUuid = "test-dataset-uuid", extra = {}) { - return { - id: `createVersionBtn-${datasetUuid}`, - dataset: { versionSetup: "false", processing: "false" }, - addEventListener: jest.fn(), - disabled: false, - click: jest.fn(), - ...extra, - }; +function createMockVersionCreateButton( + datasetUuid = "test-dataset-uuid", + extra = {}, +) { + return { + id: `createVersionBtn-${datasetUuid}`, + dataset: { versionSetup: "false", processing: "false" }, + addEventListener: jest.fn(), + disabled: false, + click: jest.fn(), + ...extra, + } } function createVersioningActionConfig(overrides = {}) { - const datasetUuid = overrides.datasetUuid ?? "test-dataset-uuid"; - const mockPermissions = { - canEditMetadata: jest.fn(() => true), - canShare: jest.fn(() => true), - ...(overrides.permissions || {}), - }; - return { - config: { - datasetUuid, - permissions: mockPermissions, - ...overrides.config, - }, - mockPermissions, - datasetUuid, - }; + const datasetUuid = overrides.datasetUuid ?? "test-dataset-uuid" + const mockPermissions = { + canEditMetadata: jest.fn(() => true), + canShare: jest.fn(() => true), + ...(overrides.permissions || {}), + } + return { + config: { + datasetUuid, + permissions: mockPermissions, + ...overrides.config, + }, + mockPermissions, + datasetUuid, + } } function setupVersioningActionTestEnvironment(overrides = {}) { - jest.clearAllMocks(); - const { config, mockPermissions, datasetUuid } = - createVersioningActionConfig(overrides); - const mockButton = createMockVersionCreateButton(datasetUuid, overrides.button); - setupStandardUnitTest({ - useModalDomUtils: true, - getElementByIdMap: { - [`createVersionBtn-${datasetUuid}`]: mockButton, - }, - apiClientOverrides: { - post: jest.fn().mockResolvedValue({ - success: true, - version: 2, - }), - ...overrides.apiClientOverrides, - }, - window: { - listRefreshManager: { - loadTable: jest.fn().mockResolvedValue(true), - }, - location: { - reload: jest.fn(), - }, - ...overrides.window, - }, - }); - return { mockConfig: config, mockPermissions, mockButton, datasetUuid }; + jest.clearAllMocks() + const { config, mockPermissions, datasetUuid } = + createVersioningActionConfig(overrides) + const mockButton = createMockVersionCreateButton( + datasetUuid, + overrides.button, + ) + setupStandardUnitTest({ + useModalDomUtils: true, + getElementByIdMap: { + [`createVersionBtn-${datasetUuid}`]: mockButton, + }, + apiClientOverrides: { + post: jest.fn().mockResolvedValue({ + success: true, + version: 2, + }), + ...overrides.apiClientOverrides, + }, + window: { + listRefreshManager: { + loadTable: jest.fn().mockResolvedValue(true), + }, + location: { + reload: jest.fn(), + }, + ...overrides.window, + }, + }) + return { mockConfig: config, mockPermissions, mockButton, datasetUuid } } function createVersionCreationClickEvent() { - return { - preventDefault: jest.fn(), - stopPropagation: jest.fn(), - }; + return { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + } } -const LIST_REFRESH_SEP = ""; +const LIST_REFRESH_SEP = "" /** * @param {{ tableHtml?: string, modalsHtml?: string }} parts */ function createListRefreshResponseHtml(parts = {}) { - const tableHtml = parts.tableHtml ?? "
"; - const modalsHtml = parts.modalsHtml ?? ""; - if (!modalsHtml) return tableHtml; - return `${tableHtml}${LIST_REFRESH_SEP}${modalsHtml}`; + const tableHtml = parts.tableHtml ?? "
" + const modalsHtml = parts.modalsHtml ?? "" + if (!modalsHtml) return tableHtml + return `${tableHtml}${LIST_REFRESH_SEP}${modalsHtml}` } /** * @param {{ tableId?: string, modalsId?: string }} ids */ function installListRefreshDomContainers(ids = {}) { - const tableId = ids.tableId ?? "dataset-list-ajax-wrapper"; - const modalsId = ids.modalsId ?? "dataset-modals-container"; - document.body.innerHTML = ""; - const table = document.createElement("div"); - table.id = tableId.replace(/^#/, ""); - const modals = document.createElement("div"); - modals.id = modalsId.replace(/^#/, ""); - document.body.append(table, modals); - return { table, modals, tableSelector: `#${table.id}`, modalsSelector: `#${modals.id}` }; + const tableId = ids.tableId ?? "dataset-list-ajax-wrapper" + const modalsId = ids.modalsId ?? "dataset-modals-container" + document.body.innerHTML = "" + const table = document.createElement("div") + table.id = tableId.replace(/^#/, "") + const modals = document.createElement("div") + modals.id = modalsId.replace(/^#/, "") + document.body.append(table, modals) + return { + table, + modals, + tableSelector: `#${table.id}`, + modalsSelector: `#${modals.id}`, + } } module.exports = { - installBootstrapModalMocks, - createMockDownloadPermissions, - setupDownloadActionTestEnvironment, - createDefaultShareActionConfig, - setupShareActionStandardTest, - createShareSearchTestContext, - createMockVersionCreateButton, - createVersioningActionConfig, - setupVersioningActionTestEnvironment, - createVersionCreationClickEvent, - LIST_REFRESH_SEP, - createListRefreshResponseHtml, - installListRefreshDomContainers, -}; + installBootstrapModalMocks, + createMockDownloadPermissions, + setupDownloadActionTestEnvironment, + createDefaultShareActionConfig, + setupShareActionStandardTest, + createShareSearchTestContext, + createMockVersionCreateButton, + createVersioningActionConfig, + setupVersioningActionTestEnvironment, + createVersionCreationClickEvent, + LIST_REFRESH_SEP, + createListRefreshResponseHtml, + installListRefreshDomContainers, +} diff --git a/gateway/sds_gateway/static/js/actions/CaptureListSelectionManager.js b/gateway/sds_gateway/static/js/actions/CaptureListSelectionManager.js index 969c3c00a..fbae1c305 100644 --- a/gateway/sds_gateway/static/js/actions/CaptureListSelectionManager.js +++ b/gateway/sds_gateway/static/js/actions/CaptureListSelectionManager.js @@ -2,71 +2,75 @@ * Bulk capture selection on the captures list page (quick-add to dataset). */ class CaptureListSelectionManager { - /** - * @param {import("./QuickAddToDatasetManager.js").QuickAddToDatasetManager} quickAddManager - */ - constructor(quickAddManager) { - this.quickAdd = quickAddManager; - this.bulkBtn = document.getElementById("capture-list-add-to-dataset-btn"); - this.countEl = document.getElementById("capture-list-selected-count"); + /** + * @param {import("./QuickAddToDatasetManager.js").QuickAddToDatasetManager} quickAddManager + */ + constructor(quickAddManager) { + this.quickAdd = quickAddManager + this.bulkBtn = document.getElementById( + "capture-list-add-to-dataset-btn", + ) + this.countEl = document.getElementById("capture-list-selected-count") - document.addEventListener("change", (e) => { - if (!e.target.matches(".capture-select-checkbox")) return; - e.stopPropagation(); - this.updateToolbar(); - }); + document.addEventListener("change", (e) => { + if (!e.target.matches(".capture-select-checkbox")) return + e.stopPropagation() + this.updateToolbar() + }) - document.addEventListener("click", (e) => { - if (e.target.matches(".capture-select-checkbox")) { - e.stopPropagation(); - } - }); + document.addEventListener("click", (e) => { + if (e.target.matches(".capture-select-checkbox")) { + e.stopPropagation() + } + }) - this.bulkBtn?.addEventListener("click", (e) => { - e.preventDefault(); - this.openBulkModal(); - }); + this.bulkBtn?.addEventListener("click", (e) => { + e.preventDefault() + this.openBulkModal() + }) - this.updateToolbar(); - } + this.updateToolbar() + } - getSelectedUuids() { - return [ - ...document.querySelectorAll(".capture-select-checkbox:checked"), - ] - .map((cb) => cb.getAttribute("data-capture-uuid")) - .filter(Boolean); - } + getSelectedUuids() { + return [ + ...document.querySelectorAll(".capture-select-checkbox:checked"), + ] + .map((cb) => cb.getAttribute("data-capture-uuid")) + .filter(Boolean) + } - updateToolbar() { - const count = this.getSelectedUuids().length; - if (this.countEl) { - this.countEl.textContent = - count === 1 ? "1 capture selected" : `${count} captures selected`; - } - if (this.bulkBtn) { - this.bulkBtn.disabled = count === 0; - } - } + updateToolbar() { + const count = this.getSelectedUuids().length + if (this.countEl) { + this.countEl.textContent = + count === 1 + ? "1 capture selected" + : `${count} captures selected` + } + if (this.bulkBtn) { + this.bulkBtn.disabled = count === 0 + } + } - clearSelection() { - for (const cb of document.querySelectorAll( - ".capture-select-checkbox:checked", - )) { - cb.checked = false; - } - this.updateToolbar(); - } + clearSelection() { + for (const cb of document.querySelectorAll( + ".capture-select-checkbox:checked", + )) { + cb.checked = false + } + this.updateToolbar() + } - openBulkModal() { - const uuids = this.getSelectedUuids(); - if (!uuids.length || !this.quickAdd?.openForCaptureUuids) return; - this.quickAdd.openForCaptureUuids(uuids); - } + openBulkModal() { + const uuids = this.getSelectedUuids() + if (!uuids.length || !this.quickAdd?.openForCaptureUuids) return + this.quickAdd.openForCaptureUuids(uuids) + } } -window.CaptureListSelectionManager = CaptureListSelectionManager; +window.CaptureListSelectionManager = CaptureListSelectionManager if (typeof module !== "undefined" && module.exports) { - module.exports = { CaptureListSelectionManager }; + module.exports = { CaptureListSelectionManager } } diff --git a/gateway/sds_gateway/static/js/actions/DetailsActionManager.js b/gateway/sds_gateway/static/js/actions/DetailsActionManager.js index 75ecff35c..b9e77ca7e 100644 --- a/gateway/sds_gateway/static/js/actions/DetailsActionManager.js +++ b/gateway/sds_gateway/static/js/actions/DetailsActionManager.js @@ -3,83 +3,86 @@ * Thin helpers for details UI (e.g. dataset UUID copy after server-rendered modal HTML). */ class DetailsActionManager { - /** - * Wire UUID copy on a dataset details modal after HTML injection. - * @param {Element} modal - * @param {string} uuid - */ - static attachUuidCopyButton(modal, uuid) { - const copyButton = modal?.querySelector?.(".copy-uuid-btn"); - if (!copyButton || !uuid) return; + /** + * Wire UUID copy on a dataset details modal after HTML injection. + * @param {Element} modal + * @param {string} uuid + */ + static attachUuidCopyButton(modal, uuid) { + const copyButton = modal?.querySelector?.(".copy-uuid-btn") + if (!copyButton || !uuid) return - copyButton.dataset.uuid = uuid; - copyButton.replaceWith(copyButton.cloneNode(true)); - const btn = modal.querySelector(".copy-uuid-btn"); - if (!btn) return; + copyButton.dataset.uuid = uuid + copyButton.replaceWith(copyButton.cloneNode(true)) + const btn = modal.querySelector(".copy-uuid-btn") + if (!btn) return - btn.addEventListener("click", (e) => { - void DetailsActionManager.handleUuidCopy(e, uuid); - }); + btn.addEventListener("click", (e) => { + void DetailsActionManager.handleUuidCopy(e, uuid) + }) - if (window.bootstrap?.Tooltip) { - const bs = window.bootstrap; - const existing = bs.Tooltip.getInstance(btn); - if (existing) existing.dispose(); - new bs.Tooltip(btn); - } - } + if (window.bootstrap?.Tooltip) { + const bs = window.bootstrap + const existing = bs.Tooltip.getInstance(btn) + if (existing) existing.dispose() + new bs.Tooltip(btn) + } + } - static async handleUuidCopy(event, uuid) { - event.preventDefault(); - event.stopPropagation(); + static async handleUuidCopy(event, uuid) { + event.preventDefault() + event.stopPropagation() - try { - await navigator.clipboard.writeText(uuid); - await DetailsActionManager.showCopyFeedback(event.target, "Copied!"); - } catch (error) { - console.warn("Clipboard API failed, trying fallback method:", error); - try { - window.UserInputController.execCommandCopyFallback(uuid); - await DetailsActionManager.showCopyFeedback(event.target, "Copied!"); - } catch (fallbackError) { - console.error("Failed to copy UUID:", fallbackError); - await DetailsActionManager.showCopyFeedback( - event.target, - "Copy failed", - "error", - ); - } - } - } + try { + await navigator.clipboard.writeText(uuid) + await DetailsActionManager.showCopyFeedback(event.target, "Copied!") + } catch (error) { + console.warn("Clipboard API failed, trying fallback method:", error) + try { + window.UserInputController.execCommandCopyFallback(uuid) + await DetailsActionManager.showCopyFeedback( + event.target, + "Copied!", + ) + } catch (fallbackError) { + console.error("Failed to copy UUID:", fallbackError) + await DetailsActionManager.showCopyFeedback( + event.target, + "Copy failed", + "error", + ) + } + } + } - static async showCopyFeedback(button, message, type = "success") { - const copyButton = button.closest(".copy-uuid-btn") || button; - const originalTitle = copyButton.getAttribute("title"); - const originalIcon = copyButton.innerHTML; + static async showCopyFeedback(button, message, type = "success") { + const copyButton = button.closest(".copy-uuid-btn") || button + const originalTitle = copyButton.getAttribute("title") + const originalIcon = copyButton.innerHTML - const icon = type === "success" ? "check" : "x"; - const color = type === "success" ? "success" : "danger"; + const icon = type === "success" ? "check" : "x" + const color = type === "success" ? "success" : "danger" - await window.DOMUtils.renderContent(copyButton, { icon, color }); - copyButton.setAttribute("title", message); + await window.DOMUtils.renderContent(copyButton, { icon, color }) + copyButton.setAttribute("title", message) - setTimeout(() => { - copyButton.innerHTML = originalIcon; - copyButton.setAttribute("title", originalTitle); - if (window.bootstrap?.Tooltip) { - const bs = window.bootstrap; - const tooltip = bs.Tooltip.getInstance(copyButton); - if (tooltip) { - tooltip.dispose(); - } - new bs.Tooltip(copyButton); - } - }, 2000); - } + setTimeout(() => { + copyButton.innerHTML = originalIcon + copyButton.setAttribute("title", originalTitle) + if (window.bootstrap?.Tooltip) { + const bs = window.bootstrap + const tooltip = bs.Tooltip.getInstance(copyButton) + if (tooltip) { + tooltip.dispose() + } + new bs.Tooltip(copyButton) + } + }, 2000) + } } -window.DetailsActionManager = DetailsActionManager; +window.DetailsActionManager = DetailsActionManager if (typeof module !== "undefined" && module.exports) { - module.exports = { DetailsActionManager }; + module.exports = { DetailsActionManager } } diff --git a/gateway/sds_gateway/static/js/actions/DownloadActionManager.js b/gateway/sds_gateway/static/js/actions/DownloadActionManager.js index e557789de..955bc961b 100644 --- a/gateway/sds_gateway/static/js/actions/DownloadActionManager.js +++ b/gateway/sds_gateway/static/js/actions/DownloadActionManager.js @@ -5,422 +5,441 @@ */ class DownloadActionManager extends ModalManager { - /** - * Initialize download action manager - * @param {Object} config - Configuration object - */ - constructor(config) { - super(); - this.permissions = config.permissions; - this.initializeEventListeners(); - } - - /** - * Initialize event listeners - */ - initializeEventListeners() { - // Initialize web download modal buttons - this.initializeWebDownloadButtons(); - - // Initialize SDK download modal buttons - this.initializeSDKDownloadButtons(); - } - - /** - * List dropdown download (wired by ModalManager._wireWebDownloadModalTriggers). - * @param {HTMLElement} toggle - */ - openWebDownloadFromToggle(toggle) { - const target = - toggle.getAttribute("data-bs-target") || - toggle.getAttribute("href") || - ""; - const modalId = target.startsWith("#") ? target.slice(1) : target; - if (!modalId.startsWith("webDownloadModal-")) { - return; - } - const itemUuid = modalId.replace("webDownloadModal-", ""); - const modal = document.getElementById(modalId); - const itemType = modal?.getAttribute("data-item-type") || "dataset"; - - if (!this.permissions.canDownload()) { - this.showToast( - `You don't have permission to download this ${itemType}`, - "warning", - ); - return; - } - - this.openModal(modalId, { - trigger: toggle, - downloadActionManager: this, - }); - } - - /** - * After ModalManager opens a web download modal (shown.bs.modal). - * @param {HTMLElement} modal - * @param {HTMLElement|null} triggerButton - */ - prepareWebDownloadModal(modal, triggerButton) { - const itemUuid = modal.id.replace("webDownloadModal-", ""); - const itemType = modal.getAttribute("data-item-type") || "dataset"; - if (itemType === "capture") { - this.setTemporalSliderAttrs(modal.id, modal, itemUuid); - } - this.wireWebDownloadConfirm(modal.id, itemUuid, itemType, triggerButton); - } - - /** - * Initialize web download buttons on the table rows - */ - initializeWebDownloadButtons() { - const downloadButtons = document.querySelectorAll(".web-download-btn"); - - for (const button of downloadButtons) { - // Prevent duplicate event listener attachment - if (button.dataset.downloadSetup === "true") { - continue; - } - button.dataset.downloadSetup = "true"; - - button.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - - const itemUuid = button.getAttribute("data-item-uuid"); - const itemType = button.getAttribute("data-item-type"); - - if (!this.permissions.canDownload()) { - this.showToast( - `You don't have permission to download this ${itemType}`, - "warning", - ); - return; - } - - this.initializeWebDownloadModal(itemUuid, itemType, button); - }); - } - } - - /** - * Initialize or update the capture download temporal slider. Call before - * showing the modal when opening for a capture with known bounds. - * @param {number} durationMs - Total capture duration in milliseconds - * @param {number} fileCadenceMs - File cadence in milliseconds (step) - * @param {Object} opts - Optional: { perDataFileSize, totalSize, dataFilesCount, totalFilesCount, dataFilesTotalSize, captureUuid, captureStartEpochSec } - */ - initializeCaptureDownloadSlider(modalId, durationMs, fileCadenceMs, opts) { - const init = window.initializeCaptureDownloadSlider; - if (typeof init !== "function") { - console.error( - "initializeCaptureDownloadSlider not loaded; include captureDownloadSlider.js", - ); - return; - } - init(modalId, durationMs, fileCadenceMs, opts); - } - - setTemporalSliderAttrs(modalId, sourceEl, itemUuid) { - // Initialize temporal slider from data attributes on trigger button or modal - const durationMs = Number.parseInt( - sourceEl.getAttribute("data-length-of-capture-ms"), - 10, - ); - const fileCadenceMs = Number.parseInt( - sourceEl.getAttribute("data-file-cadence-ms"), - 10, - ); - const perDataFileSize = Number.parseFloat( - sourceEl.getAttribute("data-per-data-file-size"), - ); - const dataFilesCount = Number.parseInt( - sourceEl.getAttribute("data-data-files-count"), - 10, - ); - const dataFilesTotalSize = Number.parseInt( - sourceEl.getAttribute("data-total-data-file-size"), - 10, - ); - const totalSize = Number.parseInt( - sourceEl.getAttribute("data-total-size"), - 10, - ); - const totalFilesCount = Number.parseInt( - sourceEl.getAttribute("data-total-files-count"), - 10, - ); - const captureStartEpochSec = Number.parseInt( - sourceEl.getAttribute("data-capture-start-epoch-sec"), - 10, - ); - this.initializeCaptureDownloadSlider( - modalId, - Number.isNaN(durationMs) ? 0 : durationMs, - Number.isNaN(fileCadenceMs) ? 1000 : fileCadenceMs, - { - perDataFileSize: Number.isNaN(perDataFileSize) ? 0 : perDataFileSize, - totalSize: Number.isNaN(totalSize) ? 0 : totalSize, - dataFilesCount: Number.isNaN(dataFilesCount) ? 0 : dataFilesCount, - totalFilesCount: Number.isNaN(totalFilesCount) ? 0 : totalFilesCount, - dataFilesTotalSize: Number.isNaN(dataFilesTotalSize) - ? undefined - : dataFilesTotalSize, - captureUuid: itemUuid || undefined, - captureStartEpochSec: Number.isNaN(captureStartEpochSec) - ? undefined - : captureStartEpochSec, - }, - ); - } - - addTimeFilteringToFetchRequest(modalId) { - const modalEl = document.getElementById(modalId); - if (!modalEl) { - return { body: {}, isJson: false }; - } - const startTimeInput = modalEl.querySelector("#startTime"); - const endTimeInput = modalEl.querySelector("#endTime"); - const startEntry = modalEl.querySelector("#startTimeEntry"); - const endEntry = modalEl.querySelector("#endTimeEntry"); - - if (startEntry && endEntry && modalEl && modalEl.dataset.durationMs) { - const entryStart = startEntry.value.trim(); - const entryEnd = endEntry.value.trim(); - if (entryStart !== "" || entryEnd !== "") { - const durationMs = Number.parseInt(modalEl.dataset.durationMs, 10); - const startMs = entryStart === "" ? 0 : Number.parseInt(entryStart, 10); - const endMs = - entryEnd === "" ? durationMs : Number.parseInt(entryEnd, 10); - if ( - !Number.isFinite(startMs) || - !Number.isFinite(endMs) || - startMs < 0 || - endMs > durationMs || - startMs >= endMs - ) { - this.showToast( - `Please enter valid start/end times (0 ≤ start < end ≤ ${durationMs} ms).`, - "warning", - ); - return; - } - if (startTimeInput) startTimeInput.value = String(startMs); - if (endTimeInput) endTimeInput.value = String(endMs); - } - } - - const body = {}; - let isJson = true; - if ( - startTimeInput && - endTimeInput && - startTimeInput.value && - endTimeInput.value - ) { - body.start_time = startTimeInput.value; - body.end_time = endTimeInput.value; - isJson = false; - } - - return { body, isJson }; - } - - /** - * Initialize web download modal for assets - * @param {Element} button - Download button element - */ - async initializeWebDownloadModal(itemUuid, itemType, button) { - const modalId = `webDownloadModal-${itemUuid}`; - this.openModal(modalId, { - trigger: button, - downloadActionManager: this, - }); - } - - /** - * @param {string} modalId - * @param {string} itemUuid - * @param {string} itemType - * @param {HTMLElement|null} triggerButton - row/menu button; null when opened via data-bs-toggle - */ - wireWebDownloadConfirm(modalId, itemUuid, itemType, triggerButton) { - const confirmBtn = document.getElementById( - `confirmWebDownloadBtn-${itemUuid}`, - ); - if (!confirmBtn) return; - - const newConfirmBtn = confirmBtn.cloneNode(true); - confirmBtn.parentNode.replaceChild(newConfirmBtn, confirmBtn); - - const statusEl = triggerButton ?? newConfirmBtn; - - newConfirmBtn.onclick = async () => { - const originalContent = statusEl.innerHTML; - await window.DOMUtils.renderLoading(statusEl, "Processing...", { - format: "spinner", - size: "sm", - }); - statusEl.disabled = true; - - this.closeModal(modalId); - - let body = {}; - let isJson = false; - try { - if (itemType === "capture") { - const result = this.addTimeFilteringToFetchRequest(modalId); - if (!result) { - statusEl.innerHTML = originalContent; - statusEl.disabled = false; - return; - } - body = result.body; - isJson = result.isJson; - } - const response = await window.APIClient.post( - `/users/download-item/${itemType}/${itemUuid}/`, - body, - null, - isJson, - ); - - if (response.success === true) { - await window.DOMUtils.renderContent(statusEl, { - icon: "check-circle", - color: "success", - text: "Download Requested", - }); - this.showToast( - response.message || - "Download request submitted successfully! You will receive an email when ready.", - "success", - ); - } else { - await window.DOMUtils.renderContent(statusEl, { - icon: "exclamation-triangle", - color: "danger", - text: "Request Failed", - }); - this.showToast( - response.message || "Download request failed. Please try again.", - "danger", - ); - } - } catch (error) { - console.error("Download error:", error); - await window.DOMUtils.renderContent(statusEl, { - icon: "exclamation-triangle", - color: "danger", - text: "Request Failed", - }); - this.showToast( - error.message || "An error occurred while processing your request.", - "danger", - ); - } finally { - setTimeout(() => { - statusEl.innerHTML = originalContent; - statusEl.disabled = false; - }, 3000); - } - }; - } - - /** - * Initialize SDK download modal buttons - */ - initializeSDKDownloadButtons() { - // Find all SDK download buttons (by data attribute or class) - const sdkDownloadButtons = document.querySelectorAll( - '[data-action="sdk-download"], .sdk-download-btn', - ); - - for (const button of sdkDownloadButtons) { - // Prevent duplicate event listener attachment - if (button.dataset.downloadSetup === "true") { - continue; - } - button.dataset.downloadSetup = "true"; - - button.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - - const datasetUuid = button.getAttribute("data-dataset-uuid"); - - if (!datasetUuid) { - console.warn("SDK download button missing dataset-uuid attribute"); - return; - } - - this.openSDKDownloadModal(datasetUuid); - }); - } - } - - /** - * Open SDK download modal for a specific dataset - * @param {string} datasetUuid - Dataset UUID - */ - openSDKDownloadModal(datasetUuid) { - const modalId = `sdkDownloadModal-${datasetUuid}`; - const modal = document.getElementById(modalId); - if (!modal) { - console.warn(`SDK download modal not found for dataset ${datasetUuid}`); - return; - } - - // Re-initialize Prism syntax highlighting when modal is shown - modal.addEventListener( - "shown.bs.modal", - () => { - if (typeof Prism !== "undefined") { - // Highlight only within this modal - Prism.highlightAllUnder(modal); - } - }, - { once: true }, - ); - - // Use centralized openModal method - this.openModal(modalId); - } - - /** - * Check if user can download specific item - * @param {Object} item - Item object - * @returns {boolean} Whether user can download - */ - canDownloadItem(_item) { - // Check basic download permission - if (!this.permissions.canDownload()) { - return false; - } - - // Additional item-specific checks can be added here - // For example, checking if item is public, if user owns it, etc. - - return true; - } - - /** - * Cleanup resources - */ - cleanup() { - // Remove event listeners and clean up any resources - const downloadButtons = document.querySelectorAll(".web-download-btn"); - for (const button of downloadButtons) { - button.removeEventListener("click", this.initializeWebDownloadButtons); - } - } + /** + * Initialize download action manager + * @param {Object} config - Configuration object + */ + constructor(config) { + super() + this.permissions = config.permissions + this.initializeEventListeners() + } + + /** + * Initialize event listeners + */ + initializeEventListeners() { + // Initialize web download modal buttons + this.initializeWebDownloadButtons() + + // Initialize SDK download modal buttons + this.initializeSDKDownloadButtons() + } + + /** + * List dropdown download (wired by ModalManager._wireWebDownloadModalTriggers). + * @param {HTMLElement} toggle + */ + openWebDownloadFromToggle(toggle) { + const target = + toggle.getAttribute("data-bs-target") || + toggle.getAttribute("href") || + "" + const modalId = target.startsWith("#") ? target.slice(1) : target + if (!modalId.startsWith("webDownloadModal-")) { + return + } + const itemUuid = modalId.replace("webDownloadModal-", "") + const modal = document.getElementById(modalId) + const itemType = modal?.getAttribute("data-item-type") || "dataset" + + if (!this.permissions.canDownload()) { + this.showToast( + `You don't have permission to download this ${itemType}`, + "warning", + ) + return + } + + this.openModal(modalId, { + trigger: toggle, + downloadActionManager: this, + }) + } + + /** + * After ModalManager opens a web download modal (shown.bs.modal). + * @param {HTMLElement} modal + * @param {HTMLElement|null} triggerButton + */ + prepareWebDownloadModal(modal, triggerButton) { + const itemUuid = modal.id.replace("webDownloadModal-", "") + const itemType = modal.getAttribute("data-item-type") || "dataset" + if (itemType === "capture") { + this.setTemporalSliderAttrs(modal.id, modal, itemUuid) + } + this.wireWebDownloadConfirm(modal.id, itemUuid, itemType, triggerButton) + } + + /** + * Initialize web download buttons on the table rows + */ + initializeWebDownloadButtons() { + const downloadButtons = document.querySelectorAll(".web-download-btn") + + for (const button of downloadButtons) { + // Prevent duplicate event listener attachment + if (button.dataset.downloadSetup === "true") { + continue + } + button.dataset.downloadSetup = "true" + + button.addEventListener("click", (e) => { + e.preventDefault() + e.stopPropagation() + + const itemUuid = button.getAttribute("data-item-uuid") + const itemType = button.getAttribute("data-item-type") + + if (!this.permissions.canDownload()) { + this.showToast( + `You don't have permission to download this ${itemType}`, + "warning", + ) + return + } + + this.initializeWebDownloadModal(itemUuid, itemType, button) + }) + } + } + + /** + * Initialize or update the capture download temporal slider. Call before + * showing the modal when opening for a capture with known bounds. + * @param {number} durationMs - Total capture duration in milliseconds + * @param {number} fileCadenceMs - File cadence in milliseconds (step) + * @param {Object} opts - Optional: { perDataFileSize, totalSize, dataFilesCount, totalFilesCount, dataFilesTotalSize, captureUuid, captureStartEpochSec } + */ + initializeCaptureDownloadSlider(modalId, durationMs, fileCadenceMs, opts) { + const init = window.initializeCaptureDownloadSlider + if (typeof init !== "function") { + console.error( + "initializeCaptureDownloadSlider not loaded; include captureDownloadSlider.js", + ) + return + } + init(modalId, durationMs, fileCadenceMs, opts) + } + + setTemporalSliderAttrs(modalId, sourceEl, itemUuid) { + // Initialize temporal slider from data attributes on trigger button or modal + const durationMs = Number.parseInt( + sourceEl.getAttribute("data-length-of-capture-ms"), + 10, + ) + const fileCadenceMs = Number.parseInt( + sourceEl.getAttribute("data-file-cadence-ms"), + 10, + ) + const perDataFileSize = Number.parseFloat( + sourceEl.getAttribute("data-per-data-file-size"), + ) + const dataFilesCount = Number.parseInt( + sourceEl.getAttribute("data-data-files-count"), + 10, + ) + const dataFilesTotalSize = Number.parseInt( + sourceEl.getAttribute("data-total-data-file-size"), + 10, + ) + const totalSize = Number.parseInt( + sourceEl.getAttribute("data-total-size"), + 10, + ) + const totalFilesCount = Number.parseInt( + sourceEl.getAttribute("data-total-files-count"), + 10, + ) + const captureStartEpochSec = Number.parseInt( + sourceEl.getAttribute("data-capture-start-epoch-sec"), + 10, + ) + this.initializeCaptureDownloadSlider( + modalId, + Number.isNaN(durationMs) ? 0 : durationMs, + Number.isNaN(fileCadenceMs) ? 1000 : fileCadenceMs, + { + perDataFileSize: Number.isNaN(perDataFileSize) + ? 0 + : perDataFileSize, + totalSize: Number.isNaN(totalSize) ? 0 : totalSize, + dataFilesCount: Number.isNaN(dataFilesCount) + ? 0 + : dataFilesCount, + totalFilesCount: Number.isNaN(totalFilesCount) + ? 0 + : totalFilesCount, + dataFilesTotalSize: Number.isNaN(dataFilesTotalSize) + ? undefined + : dataFilesTotalSize, + captureUuid: itemUuid || undefined, + captureStartEpochSec: Number.isNaN(captureStartEpochSec) + ? undefined + : captureStartEpochSec, + }, + ) + } + + addTimeFilteringToFetchRequest(modalId) { + const modalEl = document.getElementById(modalId) + if (!modalEl) { + return { body: {}, isJson: false } + } + const startTimeInput = modalEl.querySelector("#startTime") + const endTimeInput = modalEl.querySelector("#endTime") + const startEntry = modalEl.querySelector("#startTimeEntry") + const endEntry = modalEl.querySelector("#endTimeEntry") + + if (startEntry && endEntry && modalEl && modalEl.dataset.durationMs) { + const entryStart = startEntry.value.trim() + const entryEnd = endEntry.value.trim() + if (entryStart !== "" || entryEnd !== "") { + const durationMs = Number.parseInt( + modalEl.dataset.durationMs, + 10, + ) + const startMs = + entryStart === "" ? 0 : Number.parseInt(entryStart, 10) + const endMs = + entryEnd === "" ? durationMs : Number.parseInt(entryEnd, 10) + if ( + !Number.isFinite(startMs) || + !Number.isFinite(endMs) || + startMs < 0 || + endMs > durationMs || + startMs >= endMs + ) { + this.showToast( + `Please enter valid start/end times (0 ≤ start < end ≤ ${durationMs} ms).`, + "warning", + ) + return + } + if (startTimeInput) startTimeInput.value = String(startMs) + if (endTimeInput) endTimeInput.value = String(endMs) + } + } + + const body = {} + let isJson = true + if ( + startTimeInput && + endTimeInput && + startTimeInput.value && + endTimeInput.value + ) { + body.start_time = startTimeInput.value + body.end_time = endTimeInput.value + isJson = false + } + + return { body, isJson } + } + + /** + * Initialize web download modal for assets + * @param {Element} button - Download button element + */ + async initializeWebDownloadModal(itemUuid, itemType, button) { + const modalId = `webDownloadModal-${itemUuid}` + this.openModal(modalId, { + trigger: button, + downloadActionManager: this, + }) + } + + /** + * @param {string} modalId + * @param {string} itemUuid + * @param {string} itemType + * @param {HTMLElement|null} triggerButton - row/menu button; null when opened via data-bs-toggle + */ + wireWebDownloadConfirm(modalId, itemUuid, itemType, triggerButton) { + const confirmBtn = document.getElementById( + `confirmWebDownloadBtn-${itemUuid}`, + ) + if (!confirmBtn) return + + const newConfirmBtn = confirmBtn.cloneNode(true) + confirmBtn.parentNode.replaceChild(newConfirmBtn, confirmBtn) + + const statusEl = triggerButton ?? newConfirmBtn + + newConfirmBtn.onclick = async () => { + const originalContent = statusEl.innerHTML + await window.DOMUtils.renderLoading(statusEl, "Processing...", { + format: "spinner", + size: "sm", + }) + statusEl.disabled = true + + this.closeModal(modalId) + + let body = {} + let isJson = false + try { + if (itemType === "capture") { + const result = this.addTimeFilteringToFetchRequest(modalId) + if (!result) { + statusEl.innerHTML = originalContent + statusEl.disabled = false + return + } + body = result.body + isJson = result.isJson + } + const response = await window.APIClient.post( + `/users/download-item/${itemType}/${itemUuid}/`, + body, + null, + isJson, + ) + + if (response.success === true) { + await window.DOMUtils.renderContent(statusEl, { + icon: "check-circle", + color: "success", + text: "Download Requested", + }) + this.showToast( + response.message || + "Download request submitted successfully! You will receive an email when ready.", + "success", + ) + } else { + await window.DOMUtils.renderContent(statusEl, { + icon: "exclamation-triangle", + color: "danger", + text: "Request Failed", + }) + this.showToast( + response.message || + "Download request failed. Please try again.", + "danger", + ) + } + } catch (error) { + console.error("Download error:", error) + await window.DOMUtils.renderContent(statusEl, { + icon: "exclamation-triangle", + color: "danger", + text: "Request Failed", + }) + this.showToast( + error.message || + "An error occurred while processing your request.", + "danger", + ) + } finally { + setTimeout(() => { + statusEl.innerHTML = originalContent + statusEl.disabled = false + }, 3000) + } + } + } + + /** + * Initialize SDK download modal buttons + */ + initializeSDKDownloadButtons() { + // Find all SDK download buttons (by data attribute or class) + const sdkDownloadButtons = document.querySelectorAll( + '[data-action="sdk-download"], .sdk-download-btn', + ) + + for (const button of sdkDownloadButtons) { + // Prevent duplicate event listener attachment + if (button.dataset.downloadSetup === "true") { + continue + } + button.dataset.downloadSetup = "true" + + button.addEventListener("click", (e) => { + e.preventDefault() + e.stopPropagation() + + const datasetUuid = button.getAttribute("data-dataset-uuid") + + if (!datasetUuid) { + console.warn( + "SDK download button missing dataset-uuid attribute", + ) + return + } + + this.openSDKDownloadModal(datasetUuid) + }) + } + } + + /** + * Open SDK download modal for a specific dataset + * @param {string} datasetUuid - Dataset UUID + */ + openSDKDownloadModal(datasetUuid) { + const modalId = `sdkDownloadModal-${datasetUuid}` + const modal = document.getElementById(modalId) + if (!modal) { + console.warn( + `SDK download modal not found for dataset ${datasetUuid}`, + ) + return + } + + // Re-initialize Prism syntax highlighting when modal is shown + modal.addEventListener( + "shown.bs.modal", + () => { + if (typeof Prism !== "undefined") { + // Highlight only within this modal + Prism.highlightAllUnder(modal) + } + }, + { once: true }, + ) + + // Use centralized openModal method + this.openModal(modalId) + } + + /** + * Check if user can download specific item + * @param {Object} item - Item object + * @returns {boolean} Whether user can download + */ + canDownloadItem(_item) { + // Check basic download permission + if (!this.permissions.canDownload()) { + return false + } + + // Additional item-specific checks can be added here + // For example, checking if item is public, if user owns it, etc. + + return true + } + + /** + * Cleanup resources + */ + cleanup() { + // Remove event listeners and clean up any resources + const downloadButtons = document.querySelectorAll(".web-download-btn") + for (const button of downloadButtons) { + button.removeEventListener( + "click", + this.initializeWebDownloadButtons, + ) + } + } } // Make class available globally -window.DownloadActionManager = DownloadActionManager; +window.DownloadActionManager = DownloadActionManager // Export for ES6 modules (Jest testing) - only if in module context if (typeof module !== "undefined" && module.exports) { - module.exports = { DownloadActionManager }; + module.exports = { DownloadActionManager } } diff --git a/gateway/sds_gateway/static/js/actions/DownloadInstructionsManager.js b/gateway/sds_gateway/static/js/actions/DownloadInstructionsManager.js index 0bd4475b2..41af43b78 100644 --- a/gateway/sds_gateway/static/js/actions/DownloadInstructionsManager.js +++ b/gateway/sds_gateway/static/js/actions/DownloadInstructionsManager.js @@ -3,75 +3,78 @@ * Handles all download instructions-related actions */ class DownloadInstructionsManager { - /** - * Initialize download instructions manager - */ - constructor() { - this.initializeEventListeners(); - } + /** + * Initialize download instructions manager + */ + constructor() { + this.initializeEventListeners() + } - initializeEventListeners() { - // Initialize event listeners for download instructions - this.initializeCopyButtons(); - } + initializeEventListeners() { + // Initialize event listeners for download instructions + this.initializeCopyButtons() + } - initializeCopyButtons() { - const manager = this; + initializeCopyButtons() { + const manager = this - // Initialize copy buttons for download instructions - const copyButtons = document.querySelectorAll(".copy-btn"); + // Initialize copy buttons for download instructions + const copyButtons = document.querySelectorAll(".copy-btn") - for (const button of copyButtons) { - button.addEventListener("click", function () { - const targetId = this.getAttribute("data-clipboard-target"); - const codeElement = document.querySelector(targetId); + for (const button of copyButtons) { + button.addEventListener("click", function () { + const targetId = this.getAttribute("data-clipboard-target") + const codeElement = document.querySelector(targetId) - if (codeElement) { - const textToCopy = codeElement.textContent; + if (codeElement) { + const textToCopy = codeElement.textContent - // Use modern clipboard API if available - if (navigator.clipboard && window.isSecureContext) { - navigator.clipboard - .writeText(textToCopy) - .then(() => { - manager.showCopySuccess(this); - }) - .catch(() => { - manager.fallbackCopyTextToClipboard(textToCopy, this); - }); - } else { - manager.fallbackCopyTextToClipboard(textToCopy, this); - } - } - }); - } - } + // Use modern clipboard API if available + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard + .writeText(textToCopy) + .then(() => { + manager.showCopySuccess(this) + }) + .catch(() => { + manager.fallbackCopyTextToClipboard( + textToCopy, + this, + ) + }) + } else { + manager.fallbackCopyTextToClipboard(textToCopy, this) + } + } + }) + } + } - showCopySuccess(button) { - const originalText = button.innerHTML; - button.innerHTML = ' Copied!'; - button.classList.add("copied"); + showCopySuccess(button) { + const originalText = button.innerHTML + button.innerHTML = ' Copied!' + button.classList.add("copied") - setTimeout(() => { - button.innerHTML = originalText; - button.classList.remove("copied"); - }, 2000); - } + setTimeout(() => { + button.innerHTML = originalText + button.classList.remove("copied") + }, 2000) + } - fallbackCopyTextToClipboard(text, button) { - try { - window.UserInputController.execCommandCopyFallback(text); - this.showCopySuccess(button); - } catch (err) { - console.error("Fallback: Oops, unable to copy", err); - } - } + fallbackCopyTextToClipboard(text, button) { + try { + window.UserInputController.execCommandCopyFallback(text) + this.showCopySuccess(button) + } catch (err) { + console.error("Fallback: Oops, unable to copy", err) + } + } } // Make class available globally -window.DownloadInstructionsManager = DownloadInstructionsManager; +window.DownloadInstructionsManager = DownloadInstructionsManager // Export for ES6 modules (Jest testing) - only if in module context if (typeof module !== "undefined" && module.exports) { - module.exports = { DownloadInstructionsManager }; + module.exports = { DownloadInstructionsManager } } diff --git a/gateway/sds_gateway/static/js/actions/PublishActionManager.js b/gateway/sds_gateway/static/js/actions/PublishActionManager.js index 111b5fcfc..92912f7bc 100644 --- a/gateway/sds_gateway/static/js/actions/PublishActionManager.js +++ b/gateway/sds_gateway/static/js/actions/PublishActionManager.js @@ -3,317 +3,332 @@ * Handles all publish-related actions */ class PublishActionManager extends ModalManager { - /** - * Initialize publish action manager - * @param {Object} config - Configuration object - */ - constructor(config) { - super(); - this.config = config || {}; - this.initializeEventListeners(); - } - - /** - * Cached DOM refs for a publish modal by dataset UUID. - * @param {string} datasetUuid - * @returns {{ - * publishToggle: HTMLElement | null, - * visibilitySection: HTMLElement | null, - * privateOption: HTMLElement | null, - * publicOption: HTMLElement | null, - * publicWarning: HTMLElement | null, - * publishBtn: HTMLElement | null, - * statusBadge: HTMLElement | null, - * }} - */ - _getPublishModalElements(datasetUuid) { - return { - publishToggle: document.getElementById( - `publish-dataset-toggle-${datasetUuid}`, - ), - visibilitySection: document.getElementById( - `visibility-toggle-section-${datasetUuid}`, - ), - privateOption: document.getElementById(`private-option-${datasetUuid}`), - publicOption: document.getElementById(`public-option-${datasetUuid}`), - publicWarning: document.getElementById( - `public-warning-message-${datasetUuid}`, - ), - publishBtn: document.getElementById(`publishDatasetBtn-${datasetUuid}`), - statusBadge: document.getElementById( - `current-status-badge-${datasetUuid}`, - ), - }; - } - - /** - * Initialize event listeners for all publish modals on the page - */ - initializeEventListeners() { - const publishModals = document.querySelectorAll( - '[id^="publish-dataset-modal-"]', - ); - - for (const modal of publishModals) { - const datasetUuid = modal.getAttribute("data-dataset-uuid"); - if (!datasetUuid) continue; - - const { - publishToggle, - visibilitySection, - privateOption, - publicOption, - publicWarning, - publishBtn, - statusBadge, - } = this._getPublishModalElements(datasetUuid); - - if (statusBadge && !statusBadge.hasAttribute("data-initial-text")) { - statusBadge.setAttribute("data-initial-text", statusBadge.textContent); - statusBadge.setAttribute("data-initial-class", statusBadge.className); - } - - this.setupModalReset(modal, datasetUuid); - - if (publishToggle) { - publishToggle.addEventListener("change", () => { - this.handlePublishToggleChange( - publishToggle, - visibilitySection, - statusBadge, - ); - }); - } - - if (publicOption) { - publicOption.addEventListener("change", () => { - if (publicOption.checked && publicWarning) { - publicWarning.classList.remove("d-none"); - } - }); - } - - if (privateOption) { - privateOption.addEventListener("change", () => { - if (privateOption.checked && publicWarning) { - publicWarning.classList.add("d-none"); - } - }); - } - - if (publishBtn) { - publishBtn.addEventListener("click", () => { - this.handlePublish( - datasetUuid, - statusBadge, - publishToggle, - privateOption, - publicOption, - ); - }); - } - } - - for (const btn of document.querySelectorAll(".publish-dataset-btn")) { - const datasetUuid = btn.getAttribute("data-dataset-uuid"); - if (!datasetUuid) continue; - - const modalId = `publish-dataset-modal-${datasetUuid}`; - const modal = document.getElementById(modalId); - if (!modal) { - console.warn( - `Publish button found but modal not found for dataset ${datasetUuid}. Button will be disabled.`, - ); - btn.disabled = true; - btn.classList.add("disabled"); - continue; - } - - btn.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - this.openPublishModal(datasetUuid); - }); - } - } - - openPublishModal(datasetUuid) { - const modalId = `publish-dataset-modal-${datasetUuid}`; - const modal = document.getElementById(modalId); - if (!modal) { - console.warn( - `Publish modal not found for dataset ${datasetUuid}. The modal may not be available for this dataset.`, - ); - this.showToast( - "Publish functionality is not available for this dataset.", - "error", - ); - return; - } - - this.openModal(modalId); - } - - handlePublishToggleChange(publishToggle, visibilitySection, statusBadge) { - if (publishToggle.checked) { - if (statusBadge) { - statusBadge.textContent = "Final"; - statusBadge.className = "badge bg-success"; - } - if (visibilitySection) { - visibilitySection.classList.remove("d-none"); - } - } else { - if (statusBadge) { - statusBadge.textContent = "Draft"; - statusBadge.className = "badge bg-secondary"; - } - if (visibilitySection) { - visibilitySection.classList.add("d-none"); - } - } - } - - async handlePublish( - datasetUuid, - statusBadge, - publishToggle, - privateOption, - publicOption, - ) { - if (!window.APIClient) { - console.error("APIClient not available"); - return; - } - - try { - const status = publishToggle?.checked - ? "final" - : statusBadge?.textContent?.toLowerCase() || "draft"; - const isPublic = publicOption?.checked - ? "true" - : privateOption?.checked - ? "false" - : "false"; - - const data = { - status: status, - is_public: isPublic, - }; - - const { publishBtn } = this._getPublishModalElements(datasetUuid); - if (publishBtn) { - publishBtn.disabled = true; - publishBtn.innerHTML = - 'Publishing...'; - } - - const url = `/users/publish-dataset/${datasetUuid}/`; - const response = await window.APIClient.post(url, data); - - if (response.success) { - this.showToast( - response.message || "Dataset published successfully.", - "success", - ); - - const modalId = `publish-dataset-modal-${datasetUuid}`; - this.closeModal(modalId); - - setTimeout(() => { - window.location.reload(); - }, 1000); - } else { - this.showToast( - response.error || "An error occurred while publishing the dataset.", - "error", - ); - - if (publishBtn) { - publishBtn.disabled = false; - publishBtn.innerHTML = "Publish"; - } - } - } catch (error) { - console.error("Error publishing dataset:", error); - this.showToast( - error.message || "An error occurred while publishing the dataset.", - "error", - ); - - const { publishBtn: pb } = this._getPublishModalElements(datasetUuid); - if (pb) { - pb.disabled = false; - pb.innerHTML = "Publish"; - } - } - } - - setupModalReset(modal, datasetUuid) { - modal.addEventListener("hidden.bs.modal", () => { - this.resetModalState(datasetUuid); - }); - } - - resetModalState(datasetUuid) { - const { - publishToggle, - visibilitySection, - privateOption, - publicOption, - publicWarning, - statusBadge, - publishBtn, - } = this._getPublishModalElements(datasetUuid); - - if (publishToggle) { - publishToggle.checked = publishToggle.defaultChecked; - } - - if (privateOption) { - privateOption.checked = privateOption.defaultChecked; - } - - if (publicOption) { - publicOption.checked = publicOption.defaultChecked; - } - - if (visibilitySection && publishToggle) { - if (publishToggle.defaultChecked) { - visibilitySection.classList.remove("d-none"); - } else { - visibilitySection.classList.add("d-none"); - } - } - - if (publicWarning && publicOption) { - if (publicOption.defaultChecked) { - publicWarning.classList.remove("d-none"); - } else { - publicWarning.classList.add("d-none"); - } - } - - if (statusBadge) { - const initialText = statusBadge.getAttribute("data-initial-text"); - const initialClass = statusBadge.getAttribute("data-initial-class"); - if (initialText) { - statusBadge.textContent = initialText; - } - if (initialClass) { - statusBadge.className = initialClass; - } - } - - if (publishBtn) { - publishBtn.disabled = false; - publishBtn.innerHTML = "Publish"; - } - } + /** + * Initialize publish action manager + * @param {Object} config - Configuration object + */ + constructor(config) { + super() + this.config = config || {} + this.initializeEventListeners() + } + + /** + * Cached DOM refs for a publish modal by dataset UUID. + * @param {string} datasetUuid + * @returns {{ + * publishToggle: HTMLElement | null, + * visibilitySection: HTMLElement | null, + * privateOption: HTMLElement | null, + * publicOption: HTMLElement | null, + * publicWarning: HTMLElement | null, + * publishBtn: HTMLElement | null, + * statusBadge: HTMLElement | null, + * }} + */ + _getPublishModalElements(datasetUuid) { + return { + publishToggle: document.getElementById( + `publish-dataset-toggle-${datasetUuid}`, + ), + visibilitySection: document.getElementById( + `visibility-toggle-section-${datasetUuid}`, + ), + privateOption: document.getElementById( + `private-option-${datasetUuid}`, + ), + publicOption: document.getElementById( + `public-option-${datasetUuid}`, + ), + publicWarning: document.getElementById( + `public-warning-message-${datasetUuid}`, + ), + publishBtn: document.getElementById( + `publishDatasetBtn-${datasetUuid}`, + ), + statusBadge: document.getElementById( + `current-status-badge-${datasetUuid}`, + ), + } + } + + /** + * Initialize event listeners for all publish modals on the page + */ + initializeEventListeners() { + const publishModals = document.querySelectorAll( + '[id^="publish-dataset-modal-"]', + ) + + for (const modal of publishModals) { + const datasetUuid = modal.getAttribute("data-dataset-uuid") + if (!datasetUuid) continue + + const { + publishToggle, + visibilitySection, + privateOption, + publicOption, + publicWarning, + publishBtn, + statusBadge, + } = this._getPublishModalElements(datasetUuid) + + if (statusBadge && !statusBadge.hasAttribute("data-initial-text")) { + statusBadge.setAttribute( + "data-initial-text", + statusBadge.textContent, + ) + statusBadge.setAttribute( + "data-initial-class", + statusBadge.className, + ) + } + + this.setupModalReset(modal, datasetUuid) + + if (publishToggle) { + publishToggle.addEventListener("change", () => { + this.handlePublishToggleChange( + publishToggle, + visibilitySection, + statusBadge, + ) + }) + } + + if (publicOption) { + publicOption.addEventListener("change", () => { + if (publicOption.checked && publicWarning) { + publicWarning.classList.remove("d-none") + } + }) + } + + if (privateOption) { + privateOption.addEventListener("change", () => { + if (privateOption.checked && publicWarning) { + publicWarning.classList.add("d-none") + } + }) + } + + if (publishBtn) { + publishBtn.addEventListener("click", () => { + this.handlePublish( + datasetUuid, + statusBadge, + publishToggle, + privateOption, + publicOption, + ) + }) + } + } + + for (const btn of document.querySelectorAll(".publish-dataset-btn")) { + const datasetUuid = btn.getAttribute("data-dataset-uuid") + if (!datasetUuid) continue + + const modalId = `publish-dataset-modal-${datasetUuid}` + const modal = document.getElementById(modalId) + if (!modal) { + console.warn( + `Publish button found but modal not found for dataset ${datasetUuid}. Button will be disabled.`, + ) + btn.disabled = true + btn.classList.add("disabled") + continue + } + + btn.addEventListener("click", (e) => { + e.preventDefault() + e.stopPropagation() + this.openPublishModal(datasetUuid) + }) + } + } + + openPublishModal(datasetUuid) { + const modalId = `publish-dataset-modal-${datasetUuid}` + const modal = document.getElementById(modalId) + if (!modal) { + console.warn( + `Publish modal not found for dataset ${datasetUuid}. The modal may not be available for this dataset.`, + ) + this.showToast( + "Publish functionality is not available for this dataset.", + "error", + ) + return + } + + this.openModal(modalId) + } + + handlePublishToggleChange(publishToggle, visibilitySection, statusBadge) { + if (publishToggle.checked) { + if (statusBadge) { + statusBadge.textContent = "Final" + statusBadge.className = "badge bg-success" + } + if (visibilitySection) { + visibilitySection.classList.remove("d-none") + } + } else { + if (statusBadge) { + statusBadge.textContent = "Draft" + statusBadge.className = "badge bg-secondary" + } + if (visibilitySection) { + visibilitySection.classList.add("d-none") + } + } + } + + async handlePublish( + datasetUuid, + statusBadge, + publishToggle, + privateOption, + publicOption, + ) { + if (!window.APIClient) { + console.error("APIClient not available") + return + } + + try { + const status = publishToggle?.checked + ? "final" + : statusBadge?.textContent?.toLowerCase() || "draft" + const isPublic = publicOption?.checked + ? "true" + : privateOption?.checked + ? "false" + : "false" + + const data = { + status: status, + is_public: isPublic, + } + + const { publishBtn } = this._getPublishModalElements(datasetUuid) + if (publishBtn) { + publishBtn.disabled = true + publishBtn.innerHTML = + 'Publishing...' + } + + const url = `/users/publish-dataset/${datasetUuid}/` + const response = await window.APIClient.post(url, data) + + if (response.success) { + this.showToast( + response.message || "Dataset published successfully.", + "success", + ) + + const modalId = `publish-dataset-modal-${datasetUuid}` + this.closeModal(modalId) + + setTimeout(() => { + window.location.reload() + }, 1000) + } else { + this.showToast( + response.error || + "An error occurred while publishing the dataset.", + "error", + ) + + if (publishBtn) { + publishBtn.disabled = false + publishBtn.innerHTML = "Publish" + } + } + } catch (error) { + console.error("Error publishing dataset:", error) + this.showToast( + error.message || + "An error occurred while publishing the dataset.", + "error", + ) + + const { publishBtn: pb } = + this._getPublishModalElements(datasetUuid) + if (pb) { + pb.disabled = false + pb.innerHTML = "Publish" + } + } + } + + setupModalReset(modal, datasetUuid) { + modal.addEventListener("hidden.bs.modal", () => { + this.resetModalState(datasetUuid) + }) + } + + resetModalState(datasetUuid) { + const { + publishToggle, + visibilitySection, + privateOption, + publicOption, + publicWarning, + statusBadge, + publishBtn, + } = this._getPublishModalElements(datasetUuid) + + if (publishToggle) { + publishToggle.checked = publishToggle.defaultChecked + } + + if (privateOption) { + privateOption.checked = privateOption.defaultChecked + } + + if (publicOption) { + publicOption.checked = publicOption.defaultChecked + } + + if (visibilitySection && publishToggle) { + if (publishToggle.defaultChecked) { + visibilitySection.classList.remove("d-none") + } else { + visibilitySection.classList.add("d-none") + } + } + + if (publicWarning && publicOption) { + if (publicOption.defaultChecked) { + publicWarning.classList.remove("d-none") + } else { + publicWarning.classList.add("d-none") + } + } + + if (statusBadge) { + const initialText = statusBadge.getAttribute("data-initial-text") + const initialClass = statusBadge.getAttribute("data-initial-class") + if (initialText) { + statusBadge.textContent = initialText + } + if (initialClass) { + statusBadge.className = initialClass + } + } + + if (publishBtn) { + publishBtn.disabled = false + publishBtn.innerHTML = "Publish" + } + } } -window.PublishActionManager = PublishActionManager; +window.PublishActionManager = PublishActionManager if (typeof module !== "undefined" && module.exports) { - module.exports = { PublishActionManager }; + module.exports = { PublishActionManager } } diff --git a/gateway/sds_gateway/static/js/actions/QuickAddToDatasetManager.js b/gateway/sds_gateway/static/js/actions/QuickAddToDatasetManager.js index 0e75b3fae..3088d5615 100644 --- a/gateway/sds_gateway/static/js/actions/QuickAddToDatasetManager.js +++ b/gateway/sds_gateway/static/js/actions/QuickAddToDatasetManager.js @@ -3,298 +3,306 @@ * Handles opening the quick-add modal, loading datasets, and adding a capture to a dataset. */ class QuickAddToDatasetManager extends ModalManager { - constructor() { - super(); - this.modalId = "quickAddToDatasetModal"; - this.modalEl = document.getElementById(this.modalId); - this.currentCaptureUuid = null; - this.currentCaptureName = null; - /** @type {string[]|null} When set, call quick-add API once per UUID (e.g. from file list "Add" button) */ - this.currentCaptureUuids = null; - if (!this.modalEl) return; - this.quickAddUrl = this.modalEl.getAttribute("data-quick-add-url"); - this.datasetsUrl = this.modalEl.getAttribute("data-datasets-url"); - this.selectEl = document.getElementById("quick-add-dataset-select"); - this.confirmBtn = document.getElementById("quick-add-confirm-btn"); - this.messageEl = document.getElementById("quick-add-message"); - this.captureNameEl = document.getElementById("quick-add-capture-name"); - this.initializeEventListeners(); - } + constructor() { + super() + this.modalId = "quickAddToDatasetModal" + this.modalEl = document.getElementById(this.modalId) + this.currentCaptureUuid = null + this.currentCaptureName = null + /** @type {string[]|null} When set, call quick-add API once per UUID (e.g. from file list "Add" button) */ + this.currentCaptureUuids = null + if (!this.modalEl) return + this.quickAddUrl = this.modalEl.getAttribute("data-quick-add-url") + this.datasetsUrl = this.modalEl.getAttribute("data-datasets-url") + this.selectEl = document.getElementById("quick-add-dataset-select") + this.confirmBtn = document.getElementById("quick-add-confirm-btn") + this.messageEl = document.getElementById("quick-add-message") + this.captureNameEl = document.getElementById("quick-add-capture-name") + this.initializeEventListeners() + } - initializeEventListeners() { - // Delegate click on "Add to dataset" buttons (e.g. in table dropdown) - document.addEventListener("click", (e) => { - const btn = e.target.closest(".add-to-dataset-btn"); - if (!btn) return; - e.preventDefault(); - e.stopPropagation(); - const uuid = btn.getAttribute("data-capture-uuid"); - if (!uuid) return; - this.openForSingleCapture( - uuid, - btn.getAttribute("data-capture-name") || "This capture", - ); - }); + initializeEventListeners() { + // Delegate click on "Add to dataset" buttons (e.g. in table dropdown) + document.addEventListener("click", (e) => { + const btn = e.target.closest(".add-to-dataset-btn") + if (!btn) return + e.preventDefault() + e.stopPropagation() + const uuid = btn.getAttribute("data-capture-uuid") + if (!uuid) return + this.openForSingleCapture( + uuid, + btn.getAttribute("data-capture-name") || "This capture", + ) + }) - if (!this.modalEl) return; + if (!this.modalEl) return - // When modal is shown, load datasets and apply state (single vs multi from file list) - this.modalEl.addEventListener("show.bs.modal", () => { - this.resetMessage(); - const rawIds = this.modalEl.dataset.captureUuids; - if (rawIds) { - try { - this.currentCaptureUuids = JSON.parse(rawIds); - this.currentCaptureUuid = null; - this.currentCaptureName = null; - const n = this.currentCaptureUuids.length; - if (this.captureNameEl) { - this.captureNameEl.textContent = - n === 1 ? "1 capture" : `${n} captures`; - } - delete this.modalEl.dataset.captureUuids; - } catch (_) { - this.currentCaptureUuids = null; - } - } else { - this.currentCaptureUuids = null; - if (this.captureNameEl) { - this.captureNameEl.textContent = - this.currentCaptureName || "This capture"; - } - } - this.loadDatasets(); - }); + // When modal is shown, load datasets and apply state (single vs multi from file list) + this.modalEl.addEventListener("show.bs.modal", () => { + this.resetMessage() + const rawIds = this.modalEl.dataset.captureUuids + if (rawIds) { + try { + this.currentCaptureUuids = JSON.parse(rawIds) + this.currentCaptureUuid = null + this.currentCaptureName = null + const n = this.currentCaptureUuids.length + if (this.captureNameEl) { + this.captureNameEl.textContent = + n === 1 ? "1 capture" : `${n} captures` + } + delete this.modalEl.dataset.captureUuids + } catch (_) { + this.currentCaptureUuids = null + } + } else { + this.currentCaptureUuids = null + if (this.captureNameEl) { + this.captureNameEl.textContent = + this.currentCaptureName || "This capture" + } + } + this.loadDatasets() + }) - // When dataset select changes, enable/disable Add button - if (this.selectEl) { - this.selectEl.addEventListener("change", () => { - if (this.confirmBtn) { - this.confirmBtn.disabled = !this.selectEl.value; - } - }); - } + // When dataset select changes, enable/disable Add button + if (this.selectEl) { + this.selectEl.addEventListener("change", () => { + if (this.confirmBtn) { + this.confirmBtn.disabled = !this.selectEl.value + } + }) + } - // Add button click - if (this.confirmBtn) { - this.confirmBtn.addEventListener("click", () => this.handleAdd()); - } - } + // Add button click + if (this.confirmBtn) { + this.confirmBtn.addEventListener("click", () => this.handleAdd()) + } + } - /** Open modal for one capture (row actions menu). */ - openForSingleCapture(captureUuid, captureName) { - if (!this.modalEl) return; - delete this.modalEl.dataset.captureUuids; - this.currentCaptureUuids = null; - this.currentCaptureUuid = captureUuid; - this.currentCaptureName = captureName || "This capture"; - this.openModal(this.modalId); - } + /** Open modal for one capture (row actions menu). */ + openForSingleCapture(captureUuid, captureName) { + if (!this.modalEl) return + delete this.modalEl.dataset.captureUuids + this.currentCaptureUuids = null + this.currentCaptureUuid = captureUuid + this.currentCaptureName = captureName || "This capture" + this.openModal(this.modalId) + } - /** Open modal for multiple selected captures (list bulk action). */ - openForCaptureUuids(captureUuids) { - if (!this.modalEl || !captureUuids?.length) return; - this.currentCaptureUuid = null; - this.currentCaptureName = null; - this.currentCaptureUuids = captureUuids; - this.modalEl.dataset.captureUuids = JSON.stringify(captureUuids); - this.openModal(this.modalId); - } + /** Open modal for multiple selected captures (list bulk action). */ + openForCaptureUuids(captureUuids) { + if (!this.modalEl || !captureUuids?.length) return + this.currentCaptureUuid = null + this.currentCaptureName = null + this.currentCaptureUuids = captureUuids + this.modalEl.dataset.captureUuids = JSON.stringify(captureUuids) + this.openModal(this.modalId) + } - resetMessage() { - if (this.messageEl) { - this.messageEl.innerHTML = ""; - this.messageEl.classList.add("d-none"); - this.messageEl.classList.remove( - "alert-success", - "alert-danger", - "alert-warning", - ); - } - if (this.confirmBtn) { - this.confirmBtn.disabled = true; - } - if (this.selectEl) { - this.selectEl.innerHTML = ''; - } - } + resetMessage() { + if (this.messageEl) { + this.messageEl.innerHTML = "" + this.messageEl.classList.add("d-none") + this.messageEl.classList.remove( + "alert-success", + "alert-danger", + "alert-warning", + ) + } + if (this.confirmBtn) { + this.confirmBtn.disabled = true + } + if (this.selectEl) { + this.selectEl.innerHTML = '' + } + } - /** Inline alert in the quick-add modal via {@link DOMUtils.showMessage}. */ - showInlineMessage(text, type) { - if (!this.messageEl) return; - this.messageEl.classList.remove("d-none"); - window.DOMUtils?.show?.(this.messageEl); - const variant = - type === "danger" || type === "error" - ? "danger" - : type === "success" || type === "warning" || type === "info" - ? type - : "danger"; - void this.showMessageInTarget(text, this.messageEl, { - variant, - presentation: "alert", - templateContext: { - icon: - variant === "warning" ? "exclamation-triangle" : "exclamation-circle", - }, - }); - } + /** Inline alert in the quick-add modal via {@link DOMUtils.showMessage}. */ + showInlineMessage(text, type) { + if (!this.messageEl) return + this.messageEl.classList.remove("d-none") + window.DOMUtils?.show?.(this.messageEl) + const variant = + type === "danger" || type === "error" + ? "danger" + : type === "success" || type === "warning" || type === "info" + ? type + : "danger" + void this.showMessageInTarget(text, this.messageEl, { + variant, + presentation: "alert", + templateContext: { + icon: + variant === "warning" + ? "exclamation-triangle" + : "exclamation-circle", + }, + }) + } - async loadDatasets() { - if (!this.selectEl || !this.datasetsUrl) return; - this.selectEl.innerHTML = ''; - if (this.confirmBtn) this.confirmBtn.disabled = true; - try { - const response = await window.APIClient.get(this.datasetsUrl); - const datasets = response.datasets || []; - this.selectEl.innerHTML = ''; - for (const d of datasets) { - const opt = document.createElement("option"); - opt.value = d.uuid; - opt.textContent = d.name; - this.selectEl.appendChild(opt); - } - if (datasets.length === 0) { - this.showInlineMessage( - "You have no datasets you can add captures to.", - "warning", - ); - } - } catch (err) { - this.selectEl.innerHTML = ''; - const reason = err?.data?.error || err?.message || "Try again."; - this.showInlineMessage(`Failed to load datasets. ${reason}`, "danger"); - } - } + async loadDatasets() { + if (!this.selectEl || !this.datasetsUrl) return + this.selectEl.innerHTML = '' + if (this.confirmBtn) this.confirmBtn.disabled = true + try { + const response = await window.APIClient.get(this.datasetsUrl) + const datasets = response.datasets || [] + this.selectEl.innerHTML = + '' + for (const d of datasets) { + const opt = document.createElement("option") + opt.value = d.uuid + opt.textContent = d.name + this.selectEl.appendChild(opt) + } + if (datasets.length === 0) { + this.showInlineMessage( + "You have no datasets you can add captures to.", + "warning", + ) + } + } catch (err) { + this.selectEl.innerHTML = '' + const reason = err?.data?.error || err?.message || "Try again." + this.showInlineMessage( + `Failed to load datasets. ${reason}`, + "danger", + ) + } + } - async handleAdd() { - const datasetUuid = this.selectEl?.value; - if (!datasetUuid) return; - const isMulti = - Array.isArray(this.currentCaptureUuids) && - this.currentCaptureUuids.length > 0; - const isSingle = this.currentCaptureUuid && this.quickAddUrl; - if (!isMulti && !isSingle) { - this.showInlineMessage( - "Select at least one capture, or use “Add to dataset” from a row’s actions menu.", - "warning", - ); - return; - } - if (this.confirmBtn) this.confirmBtn.disabled = true; - this.resetMessage(); - if (isMulti) { - await this.handleMultiAdd(datasetUuid); - } else { - await this.handleSingleAdd(datasetUuid); - } - } + async handleAdd() { + const datasetUuid = this.selectEl?.value + if (!datasetUuid) return + const isMulti = + Array.isArray(this.currentCaptureUuids) && + this.currentCaptureUuids.length > 0 + const isSingle = this.currentCaptureUuid && this.quickAddUrl + if (!isMulti && !isSingle) { + this.showInlineMessage( + "Select at least one capture, or use “Add to dataset” from a row’s actions menu.", + "warning", + ) + return + } + if (this.confirmBtn) this.confirmBtn.disabled = true + this.resetMessage() + if (isMulti) { + await this.handleMultiAdd(datasetUuid) + } else { + await this.handleSingleAdd(datasetUuid) + } + } - formatQuickAddSummary(added, skipped, failedCount, firstErrorMessage) { - return ( - window.QuickAddApi?.formatQuickAddSummary?.( - added, - skipped, - failedCount, - firstErrorMessage, - ) ?? "Done." - ); - } + formatQuickAddSummary(added, skipped, failedCount, firstErrorMessage) { + return ( + window.QuickAddApi?.formatQuickAddSummary?.( + added, + skipped, + failedCount, + firstErrorMessage, + ) ?? "Done." + ) + } - _notifyGlobalToast(msg, alertType) { - this.showToast(msg, alertType); - } + _notifyGlobalToast(msg, alertType) { + this.showToast(msg, alertType) + } - /** - * Close the modal and fire a toast notification after it finishes hiding. - * This avoids showing the same message twice (once inside the closing modal - * and once as a toast outside it). - */ - _closeWithToast(msg, alertType) { - const afterClose = () => { - this.modalEl.removeEventListener("hidden.bs.modal", afterClose); - window.captureListSelectionManager?.clearSelection?.(); - this._notifyGlobalToast(msg, alertType); - }; - if (this.modalEl) { - this.modalEl.addEventListener("hidden.bs.modal", afterClose); - this.closeModal(this.modalEl); - } else { - window.captureListSelectionManager?.clearSelection?.(); - this._notifyGlobalToast(msg, alertType); - } - } + /** + * Close the modal and fire a toast notification after it finishes hiding. + * This avoids showing the same message twice (once inside the closing modal + * and once as a toast outside it). + */ + _closeWithToast(msg, alertType) { + const afterClose = () => { + this.modalEl.removeEventListener("hidden.bs.modal", afterClose) + window.captureListSelectionManager?.clearSelection?.() + this._notifyGlobalToast(msg, alertType) + } + if (this.modalEl) { + this.modalEl.addEventListener("hidden.bs.modal", afterClose) + this.closeModal(this.modalEl) + } else { + window.captureListSelectionManager?.clearSelection?.() + this._notifyGlobalToast(msg, alertType) + } + } - /** - * Call quick-add API once per selected capture UUID. Backend handles - * multi-channel grouping per UUID; we aggregate counts and show one message. - */ - async handleMultiAdd(datasetUuid) { - if (!this.quickAddUrl) { - this.showInlineMessage("Quick-add URL not configured.", "danger"); - if (this.confirmBtn) this.confirmBtn.disabled = false; - return; - } - const { totalAdded, totalSkipped, errorMessages } = - await window.QuickAddApi.postQuickAddCaptures( - this.quickAddUrl, - datasetUuid, - this.currentCaptureUuids, - ); - const errorCount = errorMessages.length; - const hasErrors = errorCount > 0; - const hasSuccess = totalAdded > 0 || totalSkipped > 0; - const msg = this.formatQuickAddSummary( - totalAdded, - totalSkipped, - errorCount, - errorMessages[0], - ); - if (hasSuccess || !hasErrors) { - this._closeWithToast(msg, hasErrors ? "warning" : "success"); - } else { - // All requests failed — keep modal open so the user can try again - this.showInlineMessage(msg, "warning"); - if (this.confirmBtn) this.confirmBtn.disabled = false; - } - } + /** + * Call quick-add API once per selected capture UUID. Backend handles + * multi-channel grouping per UUID; we aggregate counts and show one message. + */ + async handleMultiAdd(datasetUuid) { + if (!this.quickAddUrl) { + this.showInlineMessage("Quick-add URL not configured.", "danger") + if (this.confirmBtn) this.confirmBtn.disabled = false + return + } + const { totalAdded, totalSkipped, errorMessages } = + await window.QuickAddApi.postQuickAddCaptures( + this.quickAddUrl, + datasetUuid, + this.currentCaptureUuids, + ) + const errorCount = errorMessages.length + const hasErrors = errorCount > 0 + const hasSuccess = totalAdded > 0 || totalSkipped > 0 + const msg = this.formatQuickAddSummary( + totalAdded, + totalSkipped, + errorCount, + errorMessages[0], + ) + if (hasSuccess || !hasErrors) { + this._closeWithToast(msg, hasErrors ? "warning" : "success") + } else { + // All requests failed — keep modal open so the user can try again + this.showInlineMessage(msg, "warning") + if (this.confirmBtn) this.confirmBtn.disabled = false + } + } - async handleSingleAdd(datasetUuid) { - try { - const result = await window.QuickAddApi.postQuickAddCapture( - this.quickAddUrl, - datasetUuid, - this.currentCaptureUuid, - ); - if (result.success) { - const msg = this.formatQuickAddSummary( - result.added, - result.skipped, - result.errors.length, - result.errors[0], - ); - this._closeWithToast( - msg, - result.errors.length > 0 ? "warning" : "success", - ); - } else { - this.showInlineMessage( - result.errors[0] || "Request failed.", - "danger", - ); - if (this.confirmBtn) this.confirmBtn.disabled = false; - } - } catch (err) { - const msg = - err?.data?.error || err?.message || "Failed to add capture to dataset."; - this.showInlineMessage(msg, "danger"); - if (this.confirmBtn) this.confirmBtn.disabled = false; - } - } + async handleSingleAdd(datasetUuid) { + try { + const result = await window.QuickAddApi.postQuickAddCapture( + this.quickAddUrl, + datasetUuid, + this.currentCaptureUuid, + ) + if (result.success) { + const msg = this.formatQuickAddSummary( + result.added, + result.skipped, + result.errors.length, + result.errors[0], + ) + this._closeWithToast( + msg, + result.errors.length > 0 ? "warning" : "success", + ) + } else { + this.showInlineMessage( + result.errors[0] || "Request failed.", + "danger", + ) + if (this.confirmBtn) this.confirmBtn.disabled = false + } + } catch (err) { + const msg = + err?.data?.error || + err?.message || + "Failed to add capture to dataset." + this.showInlineMessage(msg, "danger") + if (this.confirmBtn) this.confirmBtn.disabled = false + } + } } -window.QuickAddToDatasetManager = QuickAddToDatasetManager; +window.QuickAddToDatasetManager = QuickAddToDatasetManager if (typeof module !== "undefined" && module.exports) { - module.exports = { QuickAddToDatasetManager }; + module.exports = { QuickAddToDatasetManager } } diff --git a/gateway/sds_gateway/static/js/actions/ShareActionManager.js b/gateway/sds_gateway/static/js/actions/ShareActionManager.js index 2ab66de30..6623fbdf9 100644 --- a/gateway/sds_gateway/static/js/actions/ShareActionManager.js +++ b/gateway/sds_gateway/static/js/actions/ShareActionManager.js @@ -5,1014 +5,1041 @@ * Handles all sharing-related actions and user management */ class ShareActionManager extends ModalManager { - /** - * Initialize share action manager - * @param {Object} config - Configuration object - */ - constructor(config) { - super(); - this.itemUuid = config.itemUuid; - this.itemType = config.itemType; - this.permissions = config.permissions; - this.searchTimeout = null; - this.currentRequest = null; - this.selectedUsersMap = {}; // key: input id, value: array of {name, email} - this.pendingRemovals = new Set(); // Track users marked for removal - this.pendingPermissionChanges = new Map(); // Track permission level changes - - this.initializeEventListeners(); - } - - /** - * Initialize event listeners - */ - initializeEventListeners() { - this.setupRemoveUserButtons(); - this.setupModalEventHandlers(); - } - - /** - * Setup modal event handlers - */ - setupModalEventHandlers() { - // Find the specific modal for this item - let modal = document.getElementById(`shareModal-${this.itemUuid}`); - if (!modal) { - // Try alternative modal IDs - const alternativeModalIds = [ - "manageMembersModal", - `modal-${this.itemUuid}`, - `${this.itemType}-modal`, - ]; - - for (const id of alternativeModalIds) { - modal = document.getElementById(id); - if (modal) break; - } - } - - if (!modal) { - console.error(`Modal not found for ${this.itemType}: ${this.itemUuid}`); - return; - } - - // Setup search input for this specific modal - const searchInput = modal.querySelector(".user-search-input"); - if (searchInput) { - this.setupSearchInput(searchInput); - } - - // Setup share button for this specific modal - const shareButton = document.getElementById( - `share-item-btn-${this.itemUuid}`, - ); - if (shareButton) { - this.setupShareItem(shareButton); - } - - // Setup notify checkbox functionality - this.setupNotifyCheckbox(this.itemUuid); - } - - /** - * Setup search input - * @param {Element} input - Search input element - */ - setupSearchInput(input) { - window.UserInputController.bindUserSearchInput(input, { - selectedUsersMap: this.selectedUsersMap, - getSearchTimeout: () => this.searchTimeout, - setSearchTimeout: (id) => { - this.searchTimeout = id; - }, - getDropdownForInput: (inp) => this.getDropdownForInput(inp), - hideDropdown: (d) => this.hideDropdown(d), - navigateDropdown: (items, idx, dir) => - this.navigateDropdown(items, idx, dir), - searchUsers: (query, d) => this.searchUsers(query, d), - selectUser: (item, inp) => this.selectUser(item, inp), - }); - } - - /** - * Push a default viewer user if missing, refresh chips, clear input, close dropdown. - * @param {HTMLInputElement} input - * @param {string} userEmail - * @param {string} userName - */ - _commitViewerSelection(input, userEmail, userName) { - if (!this.selectedUsersMap[input.id].some((u) => u.email === userEmail)) { - this.selectedUsersMap[input.id].push({ - name: userName, - email: userEmail, - type: "user", - permission_level: window.PermissionLevels.VIEWER, - }); - this.renderChips(input); - } - input.value = ""; - this.hideDropdown(input.closest(".user-search-dropdown")); - input.focus(); - } - - /** - * Setup share item button - * @param {Element} shareButton - Share button element - */ - setupShareItem(shareButton) { - // Prevent duplicate event listener attachment - if (shareButton.dataset.shareSetup === "true") { - return; - } - shareButton.dataset.shareSetup = "true"; - - shareButton.addEventListener("click", async () => { - await this.handleShareItem(); - }); - } - - /** - * Handle share item action - */ - async handleShareItem() { - // Get the user emails from the selected users map - const inputId = `user-search-${this.itemUuid}`; - const selectedUsers = this.selectedUsersMap[inputId] || []; - - // Create a map of user emails to their permission levels - const userPermissions = {}; - for (const user of selectedUsers) { - userPermissions[user.email] = - user.permission_level || window.PermissionLevels.VIEWER; - } - - const userEmails = selectedUsers.map((u) => u.email).join(","); - - const formData = { - "user-search": userEmails, - user_permissions: JSON.stringify(userPermissions), - }; - - // Add notify_users and notify_message if present - const notifyCheckbox = document.getElementById( - `notify-users-checkbox-${this.itemUuid}`, - ); - if (notifyCheckbox?.checked) { - formData.notify_users = "1"; - const messageTextarea = document.getElementById( - `notify-message-textarea-${this.itemUuid}`, - ); - if (messageTextarea?.value.trim()) { - formData.notify_message = messageTextarea.value.trim(); - } - } - - // Handle pending removals - if (this.pendingRemovals.size > 0) { - formData.remove_users = JSON.stringify(Array.from(this.pendingRemovals)); - } - - // Handle pending permission changes - if ( - this.pendingPermissionChanges && - this.pendingPermissionChanges.size > 0 - ) { - formData.permission_changes = JSON.stringify( - Array.from(this.pendingPermissionChanges.entries()), - ); - } - - try { - const response = await window.APIClient.post( - `/users/share-item/${this.itemType}/${this.itemUuid}/`, - formData, - ); - - if (response.success) { - // Show success message - this.showToast( - response.message || `${this.itemType} shared successfully!`, - "success", - ); - - // Close modal - this.closeModal(`shareModal-${this.itemUuid}`); - - // Refresh dataset list (await so modal/list re-init completes before user continues) - if ( - window.listRefreshManager && - typeof window.listRefreshManager.loadTable === "function" - ) { - try { - await window.listRefreshManager.loadTable(); - } catch (refreshErr) { - console.error( - "ShareActionManager: list refresh failed after share", - refreshErr, - ); - } - } else { - // Fallback: reload the page if listRefreshManager is not available - console.warn("listRefreshManager not available, reloading page"); - window.location.reload(); - } - } else { - // Show error message - const errorMessage = - response.error || - response.message || - `Error sharing ${this.itemType}`; - this.showToast(errorMessage, "danger"); - } - } catch (error) { - console.error(`Error sharing ${this.itemType}:`, error); - this.showToast( - `Error sharing ${this.itemType}. Please try again.`, - "danger", - ); - } - } - - /** - * Search users - * @param {string} query - Search query - * @param {Element} dropdown - Dropdown element - */ - async searchUsers(query, dropdown) { - // Cancel previous request if still pending - if (this.currentRequest) { - this.currentRequest.abort(); - } - - try { - this.currentRequest = new AbortController(); - // Get the search results from the API - const response = await window.APIClient.get( - `/users/share-item/${this.itemType}/${this.itemUuid}/`, - { q: query, limit: 10 }, - null, // No loading state for search - ); - // Render the HTML fragment using the template - if (response) { - const users = response || []; - const htmlResponse = await window.APIClient.post( - "/users/render-html/", - { - template: "users/components/user_search_results.html", - context: { - users: users, - }, - }, - null, - true, - ); // true = send as JSON - - // Pass the rendered HTML to displayResults - this.displayResults({ html: htmlResponse.html }, dropdown); - } else { - // No results found - this.displayResults({ html: null, results: [] }, dropdown); - } - } catch (error) { - if (error.name === "AbortError") { - return; - } - console.error("Error searching users:", error); - await this.displayError(dropdown); - } finally { - this.currentRequest = null; - } - } - - /** - * Display search results - * @param {Object} response - Response from server with html and results - * @param {Element} dropdown - Dropdown element - */ - displayResults(response, dropdown) { - const listGroup = dropdown.querySelector(".list-group"); - - // Use server-rendered HTML - if (response.html) { - listGroup.innerHTML = response.html; - } else if (response.results && response.results.length === 0) { - // Fallback for empty results - listGroup.innerHTML = - '
No users or groups found
'; - } else { - // Fallback for error case - listGroup.innerHTML = - '
Error loading results
'; - } - - this.showDropdown(dropdown); - } - - /** - * Display error in dropdown - * @param {Element} dropdown - Dropdown element - */ - async displayError(dropdown) { - const listGroup = dropdown.querySelector(".list-group"); - if (!listGroup) return; - await this.showMessageInTarget("Error loading users", listGroup, { - variant: "danger", - presentation: "alert", - templateContext: { dismissible: false }, - }); - this.showDropdown(dropdown); - } - - /** - * Navigate dropdown with keyboard - * @param {NodeList} items - Dropdown items - * @param {number} currentIndex - Current selected index - * @param {number} direction - Direction to navigate - */ - navigateDropdown(items, currentIndex, direction) { - window.UserSearchDropdown.navigateDropdown(items, currentIndex, direction); - } - - /** - * Select user from dropdown - * @param {Element} item - Selected item - * @param {Element} input - Search input - */ - selectUser(item, input) { - const userName = item.dataset.userName; - const userEmail = item.dataset.userEmail; - const userType = item.dataset.userType || "user"; - const inputId = input.id; - - if (!this.selectedUsersMap[inputId]) { - this.selectedUsersMap[inputId] = []; - } - - // Check if this user is already part of a selected group - if (userType === "user") { - const selectedGroups = this.selectedUsersMap[inputId].filter( - (u) => u.type === "group", - ); - for (const group of selectedGroups) { - // Check if this user is in the group by making an API call - this.checkUserInGroup(userEmail, group, input, userName); - return; // Exit early, we'll handle the result in the callback - } - } - - if (!this.selectedUsersMap[inputId].some((u) => u.email === userEmail)) { - // Create user object with all available data - const userData = { - name: userName, - email: userEmail, - type: userType, - permission_level: window.PermissionLevels.VIEWER, // Default permission level - }; - - // For groups, get member count from dataset attribute - if (userType === "group") { - const memberCount = item.dataset.memberCount; - if (memberCount) { - userData.member_count = Number.parseInt(memberCount, 10); - } - } - - this.selectedUsersMap[inputId].push(userData); - this.renderChips(input); - } - - input.value = ""; - this.hideDropdown(item.closest(".user-search-dropdown")); - input.focus(); - } - - /** - * Check if user is in group - * @param {string} userEmail - User email - * @param {Object} group - Group object - * @param {Element} input - Search input - * @param {string} userName - User name - */ - async checkUserInGroup(userEmail, group, input, userName) { - try { - // Extract group UUID from group.email (format: "group:uuid") - const groupUuid = group.email.replace("group:", ""); - - // Make API call to check if user is in the group - const data = await window.APIClient.get("/users/share-groups/", { - group_uuid: groupUuid, - }); - - if (data.success && data.members) { - const isUserInGroup = data.members.some( - (member) => member.email === userEmail, - ); - - if (isUserInGroup) { - // User is already in the group, show notification and don't add - this.showToast( - `${userName} is already part of the group "${group.name}"`, - "warning", - ); - input.value = ""; - this.hideDropdown(input.closest(".user-search-dropdown")); - input.focus(); - return; - } - } - - // If we get here, user is not in the group, so add them normally - this._commitViewerSelection(input, userEmail, userName); - } catch (error) { - console.error("Error checking if user is in group:", error); - // If there's an error, just add the user normally - this._commitViewerSelection(input, userEmail, userName); - } - } - - /** - * Render user chips - * @param {Element} input - Search input - */ - async renderChips(input) { - const inputId = input.id; - - // Try to find the chip container - let chipContainer = input - .closest(".user-search-input-container") - .querySelector(".selected-users-chips"); - - // If not found, try to find it in the permissions section - if (!chipContainer) { - chipContainer = input - .closest("form") - .querySelector( - ".selected-users-permissions-section .selected-users-chips", - ); - } - - if (!chipContainer) { - console.warn("Chip container not found for input:", inputId); - return; - } - - // Clear container - chipContainer.innerHTML = ""; - - // If no users selected, toggle sections and return - if ( - !this.selectedUsersMap[inputId] || - this.selectedUsersMap[inputId].length === 0 - ) { - this.toggleModalSections(inputId); - return; - } - - try { - // Filter permission levels based on item type - // For captures, only allow viewer permission - let allowedPermissionLevels = window.SHARE_PERMISSION_OPTIONS; - if (this.itemType === "capture") { - allowedPermissionLevels = [window.PermissionLevels.VIEWER]; - } - - // Request server to render using generic endpoint - const response = await window.APIClient.post( - "/users/render-html/", - { - template: "users/components/user_chips.html", - context: { - users: this.selectedUsersMap[inputId], - show_permission_select: true, - show_remove_button: true, - permission_levels: allowedPermissionLevels, - item_type: this.itemType, // Pass item type to template for conditional rendering - }, - }, - null, - true, - ); // true = send as JSON - - // Insert the server-rendered HTML - if (response.html) { - chipContainer.innerHTML = response.html; - - // Add event handlers after rendering - this.attachChipEventHandlers(chipContainer, inputId, input); - } - } catch (error) { - this.logError?.(error, chipContainer); - await this.showMessageInTarget("Error loading users", chipContainer, { - variant: "danger", - presentation: "inline", - }); - } - - // Toggle notify/message and users-with-access sections - this.toggleModalSections(inputId); - } - - /** - * Attach event handlers to rendered chips - * @param {Element} chipContainer - Chip container element - * @param {string} inputId - Input ID - * @param {Element} input - Search input element - */ - attachChipEventHandlers(chipContainer, inputId, input) { - // Add click handlers for removal buttons - const removeButtons = chipContainer.querySelectorAll(".remove-chip"); - for (const removeBtn of removeButtons) { - const userEmail = removeBtn.closest(".user-chip").dataset.userEmail; - removeBtn.onclick = () => { - this.selectedUsersMap[inputId] = this.selectedUsersMap[inputId].filter( - (u) => u.email !== userEmail, - ); - this.renderChips(input); - }; - } - - // Add change handlers for permission selects - const permissionSelects = - chipContainer.querySelectorAll(".permission-select"); - for (const permissionSelect of permissionSelects) { - permissionSelect.onchange = (e) => { - const userEmail = e.target.dataset.userEmail; - const userIndex = this.selectedUsersMap[inputId].findIndex( - (u) => u.email === userEmail, - ); - if (userIndex !== -1) { - this.selectedUsersMap[inputId][userIndex].permission_level = - e.target.value; - } - }; - } - } - - /** - * Toggle modal sections based on selections - * @param {string} inputId - Input ID - */ - toggleModalSections(inputId) { - const itemUuid = inputId.replace("user-search-", ""); - const notifySection = document.getElementById( - `notify-message-section-${itemUuid}`, - ); - const usersWithAccessSection = document.getElementById( - `users-with-access-section-${itemUuid}`, - ); - const saveBtn = document.getElementById(`share-item-btn-${itemUuid}`); - const modalDivider = document.querySelector( - `#shareModal-${itemUuid} .modal-divider`, - ); - - if ( - this.selectedUsersMap[inputId] && - this.selectedUsersMap[inputId].length > 0 - ) { - // Show notify section, hide users with access section - if (notifySection) notifySection.classList.remove("d-none"); - if (usersWithAccessSection) - usersWithAccessSection.classList.add("d-none"); - if (modalDivider) modalDivider.classList.add("d-none"); - - // Clear pending removals when adding new users - this.pendingRemovals.clear(); - - // Reset all dropdown buttons to their original state - const modal = document.getElementById(`shareModal-${itemUuid}`); - if (modal) { - for (const button of modal.querySelectorAll(".btn-icon-dropdown")) { - button.innerHTML = - ''; - button.classList.remove("btn-outline-danger"); - button.classList.add("btn-light"); - button.disabled = false; - } - } - - // Change button text to "Share" - if (saveBtn) saveBtn.textContent = "Share"; - - // Setup notify checkbox functionality - setTimeout(() => { - this.setupNotifyCheckbox(itemUuid); - }, 10); - } else { - // Hide notify section, show users with access section - if (notifySection) notifySection.classList.add("d-none"); - if (usersWithAccessSection) - usersWithAccessSection.classList.remove("d-none"); - if (modalDivider) modalDivider.classList.remove("d-none"); - - // Change button text back to "Save" - if (saveBtn) saveBtn.textContent = "Save"; - } - - // Update save button state - this.updateSaveButtonState(itemUuid); - } - - /** - * Update save button state - * @param {string} itemUuid - Item UUID - */ - updateSaveButtonState(itemUuid) { - const inputId = `user-search-${itemUuid}`; - const selectedUsers = this.selectedUsersMap[inputId] || []; - const hasSelectedUsers = selectedUsers.length > 0; - const hasPendingRemovals = this.pendingRemovals.size > 0; - const hasPendingPermissionChanges = - this.pendingPermissionChanges && this.pendingPermissionChanges.size > 0; - - const saveBtn = document.getElementById(`share-item-btn-${itemUuid}`); - const pendingMessage = document.getElementById( - `pending-changes-message-${itemUuid}`, - ); - - // Update save button - if (saveBtn) { - if ( - hasSelectedUsers || - hasPendingRemovals || - hasPendingPermissionChanges - ) { - saveBtn.disabled = false; - } else { - saveBtn.disabled = true; - } - } - - // Update pending message - if (pendingMessage) { - if (hasPendingRemovals || hasPendingPermissionChanges) { - pendingMessage.classList.remove("d-none"); - } else { - pendingMessage.classList.add("d-none"); - } - } - } - - /** - * Setup remove user buttons - */ - setupRemoveUserButtons() { - // Find the specific modal for this item - const modal = document.getElementById(`shareModal-${this.itemUuid}`); - if (!modal) { - console.error(`Modal not found for ${this.itemType}: ${this.itemUuid}`); - return; - } - - // Prevent duplicate event listener attachment - if (modal.dataset.removeButtonsSetup === "true") { - return; - } - modal.dataset.removeButtonsSetup = "true"; - - // Setup permission change buttons for this specific modal only - modal.addEventListener("click", async (e) => { - if (e.target.closest(".permission-change-btn")) { - await this.handlePermissionChange(e); - } - }); - } - - /** - * Handle permission change - * @param {Event} e - Click event - */ - async handlePermissionChange(e) { - const button = e.target.closest(".permission-change-btn"); - const userEmail = button.dataset.userEmail; - const userName = button.dataset.userName; - const itemUuid = button.dataset.itemUuid; - const itemType = button.dataset.itemType; - const permissionLevel = button.dataset.permissionLevel; - - if (!userEmail || !itemUuid || !itemType || !permissionLevel) { - console.error( - "Missing user email, item UUID, item type, or permission level", - ); - return; - } - - // Update the dropdown button text and icon to reflect the change - const dropdown = button.closest(".dropdown"); - const dropdownButton = dropdown.querySelector(".access-level-dropdown"); - const currentPermission = dropdownButton.getAttribute( - "data-current-permission", - ); - - // Don't do anything if selecting the same permission - if (permissionLevel === currentPermission) { - return; - } - - // Update the dropdown button text and icon - const iconClass = this.permissions.getPermissionIcon(permissionLevel); - dropdownButton.innerHTML = `${permissionLevel.charAt(0).toUpperCase() + permissionLevel.slice(1)}`; - dropdownButton.setAttribute("data-current-permission", permissionLevel); - - // Update checkmarks in dropdown menu - this.updateDropdownMenu(dropdown, button); - - // Update user text for removal case - if (permissionLevel === "remove") { - this.markUserForRemoval(userEmail, userName); - } else { - this.clearUserRemovalMarking(userEmail); - } - - // Handle permission level change - if (permissionLevel !== "remove") { - this.handlePermissionLevelChange( - userEmail, - userName, - itemUuid, - itemType, - permissionLevel, - ); - } - - // Update save button state - this.updateSaveButtonState(itemUuid); - - // Close the dropdown - const bsDropdown = bootstrap.Dropdown.getInstance(dropdownButton); - if (bsDropdown) { - bsDropdown.hide(); - } - } - - /** - * Update dropdown menu - * @param {Element} dropdown - Dropdown element - * @param {Element} clickedButton - Clicked button - */ - updateDropdownMenu(dropdown, clickedButton) { - const dropdownMenu = dropdown.querySelector(".access-level-menu"); - const allPermissionBtns = dropdownMenu.querySelectorAll( - ".permission-change-btn", - ); - - // Remove selected class and checkmarks from all buttons - for (const btn of allPermissionBtns) { - btn.classList.remove("selected"); - const checkmark = btn.querySelector(".bi-check"); - if (checkmark) { - checkmark.remove(); - } - } - - // Add selected class and checkmark to the clicked button - clickedButton.classList.add("selected"); - const checkmark = document.createElement("i"); - checkmark.className = "bi bi-check ms-auto"; - clickedButton.appendChild(checkmark); - } - - /** - * Mark user for removal - * @param {string} userEmail - User email - * @param {string} userName - User name - */ - markUserForRemoval(userEmail, userName) { - this.pendingRemovals.add(userEmail); - - // Remove from permission changes if it was there - if (this.pendingPermissionChanges?.has(userEmail)) { - this.pendingPermissionChanges.delete(userEmail); - } - - // Update user text styling - const userRow = document - .querySelector(`[data-user-email="${userEmail}"]`) - .closest("tr"); - const userNameElement = userRow.querySelector("h5"); - if (userNameElement) { - userNameElement.style.textDecoration = "line-through"; - userNameElement.style.opacity = "0.6"; - } - } - - /** - * Clear user removal marking - * @param {string} userEmail - User email - */ - clearUserRemovalMarking(userEmail) { - this.pendingRemovals.delete(userEmail); - - // Clear any existing text decoration or opacity - const userRow = document - .querySelector(`[data-user-email="${userEmail}"]`) - .closest("tr"); - const userNameElement = userRow.querySelector("h5"); - if (userNameElement) { - userNameElement.style.textDecoration = "none"; - userNameElement.style.opacity = "1"; - } - } - - /** - * Handle permission level change - * @param {string} userEmail - User email - * @param {string} userName - User name - * @param {string} itemUuid - Item UUID - * @param {string} itemType - Item type - * @param {string} permissionLevel - New permission level - */ - handlePermissionLevelChange( - userEmail, - userName, - itemUuid, - itemType, - permissionLevel, - ) { - // Store the permission change - if (!this.pendingPermissionChanges) { - this.pendingPermissionChanges = new Map(); - } - this.pendingPermissionChanges.set(userEmail, { - userName: userName, - itemUuid: itemUuid, - itemType: itemType, - permissionLevel: permissionLevel, - }); - } - - /** - * Get dropdown for input - * @param {Element} input - Input element - * @returns {Element|null} Dropdown element - */ - getDropdownForInput(input) { - return window.UserSearchDropdown.getDropdownForInput(input, { - itemUuid: this.itemUuid, - }); - } - - /** - * Show dropdown - * @param {Element} dropdown - Dropdown element - */ - showDropdown(dropdown) { - window.UserSearchDropdown.showDropdown(dropdown); - } - - /** - * Hide dropdown - * @param {Element} dropdown - Dropdown element - */ - hideDropdown(dropdown) { - window.UserSearchDropdown.hideDropdown(dropdown); - } - - /** - * Clear selections - */ - clearSelections() { - // Clear selected users - this.selectedUsersMap = {}; - - // Clear pending removals - this.pendingRemovals.clear(); - - // Clear pending permission changes - if (this.pendingPermissionChanges) { - this.pendingPermissionChanges.clear(); - } - - // Reset all dropdown buttons to their original state - for (const button of document.querySelectorAll(".btn-icon-dropdown")) { - button.innerHTML = - ''; - button.classList.remove("btn-outline-danger"); - button.classList.add("btn-light"); - button.disabled = false; - } - - // Reset save button state for all modals - for (const btn of document.querySelectorAll('[id^="share-item-btn-"]')) { - btn.disabled = true; - btn.textContent = "Save"; - } - - // Hide pending changes messages - for (const msg of document.querySelectorAll( - '[id^="pending-changes-message-"]', - )) { - msg.classList.add("d-none"); - } - - // Clear chips - for (const container of document.querySelectorAll( - ".selected-users-chips", - )) { - container.innerHTML = ""; - } - - // Hide notify sections and show users-with-access sections - for (const section of document.querySelectorAll( - '[id^="notify-message-section-"]', - )) { - section.classList.add("d-none"); - } - for (const section of document.querySelectorAll( - '[id^="users-with-access-section-"]', - )) { - section.classList.remove("d-none"); - } - - // Show modal dividers - for (const divider of document.querySelectorAll(".modal-divider")) { - divider.classList.remove("d-none"); - } - - // Clear search inputs - for (const input of document.querySelectorAll(".user-search-input")) { - input.value = ""; - } - - // Hide dropdowns and clear their content - for (const dropdown of document.querySelectorAll(".user-search-dropdown")) { - dropdown.classList.add("d-none"); - const listGroup = dropdown.querySelector(".list-group"); - if (listGroup) { - listGroup.innerHTML = ""; - } - } - - // Reset notify checkboxes and textareas - for (const checkbox of document.querySelectorAll( - '[id^="notify-users-checkbox-"]', - )) { - checkbox.checked = true; - const itemUuid = checkbox.id.replace("notify-users-checkbox-", ""); - this.setupNotifyCheckbox(itemUuid); - } - for (const textarea of document.querySelectorAll( - '[id^="notify-message-textarea-"]', - )) { - textarea.value = ""; - } - } - - /** - * Get permission button text with icon (legacy method) - * @param {string} permissionLevel - Permission level - * @returns {string} Button text with icon - */ - getPermissionButtonText(permissionLevel) { - // Handle undefined/null permission levels - const level = - !permissionLevel || typeof permissionLevel !== "string" - ? window.PermissionLevels.VIEWER - : permissionLevel; - - const iconClass = - this.permissions?.getPermissionIcon(level) || "bi-question-circle"; - const displayText = level.charAt(0).toUpperCase() + level.slice(1); - return `${displayText}`; - } - - /** - * Setup notify checkbox functionality - * @param {string} itemUuid - Item UUID - */ - setupNotifyCheckbox(itemUuid) { - const notifyCheckbox = document.getElementById( - `notify-users-checkbox-${itemUuid}`, - ); - const textareaContainer = document.getElementById( - `notify-message-textarea-container-${itemUuid}`, - ); - - if (!notifyCheckbox || !textareaContainer) { - // Don't log error - this is expected for modals without notify functionality - return; - } - - function toggleTextarea() { - if (notifyCheckbox.checked) { - textareaContainer.classList.add("show"); - } else { - textareaContainer.classList.remove("show"); - } - } - - // Remove any existing event listeners to prevent duplicates - notifyCheckbox.removeEventListener("change", toggleTextarea); - notifyCheckbox.addEventListener("change", toggleTextarea); - - // Call toggleTextarea immediately to set initial state - toggleTextarea(); - } -}; + /** + * Initialize share action manager + * @param {Object} config - Configuration object + */ + constructor(config) { + super() + this.itemUuid = config.itemUuid + this.itemType = config.itemType + this.permissions = config.permissions + this.searchTimeout = null + this.currentRequest = null + this.selectedUsersMap = {} // key: input id, value: array of {name, email} + this.pendingRemovals = new Set() // Track users marked for removal + this.pendingPermissionChanges = new Map() // Track permission level changes + + this.initializeEventListeners() + } + + /** + * Initialize event listeners + */ + initializeEventListeners() { + this.setupRemoveUserButtons() + this.setupModalEventHandlers() + } + + /** + * Setup modal event handlers + */ + setupModalEventHandlers() { + // Find the specific modal for this item + let modal = document.getElementById(`shareModal-${this.itemUuid}`) + if (!modal) { + // Try alternative modal IDs + const alternativeModalIds = [ + "manageMembersModal", + `modal-${this.itemUuid}`, + `${this.itemType}-modal`, + ] + + for (const id of alternativeModalIds) { + modal = document.getElementById(id) + if (modal) break + } + } + + if (!modal) { + console.error( + `Modal not found for ${this.itemType}: ${this.itemUuid}`, + ) + return + } + + // Setup search input for this specific modal + const searchInput = modal.querySelector(".user-search-input") + if (searchInput) { + this.setupSearchInput(searchInput) + } + + // Setup share button for this specific modal + const shareButton = document.getElementById( + `share-item-btn-${this.itemUuid}`, + ) + if (shareButton) { + this.setupShareItem(shareButton) + } + + // Setup notify checkbox functionality + this.setupNotifyCheckbox(this.itemUuid) + } + + /** + * Setup search input + * @param {Element} input - Search input element + */ + setupSearchInput(input) { + window.UserInputController.bindUserSearchInput(input, { + selectedUsersMap: this.selectedUsersMap, + getSearchTimeout: () => this.searchTimeout, + setSearchTimeout: (id) => { + this.searchTimeout = id + }, + getDropdownForInput: (inp) => this.getDropdownForInput(inp), + hideDropdown: (d) => this.hideDropdown(d), + navigateDropdown: (items, idx, dir) => + this.navigateDropdown(items, idx, dir), + searchUsers: (query, d) => this.searchUsers(query, d), + selectUser: (item, inp) => this.selectUser(item, inp), + }) + } + + /** + * Push a default viewer user if missing, refresh chips, clear input, close dropdown. + * @param {HTMLInputElement} input + * @param {string} userEmail + * @param {string} userName + */ + _commitViewerSelection(input, userEmail, userName) { + if ( + !this.selectedUsersMap[input.id].some((u) => u.email === userEmail) + ) { + this.selectedUsersMap[input.id].push({ + name: userName, + email: userEmail, + type: "user", + permission_level: window.PermissionLevels.VIEWER, + }) + this.renderChips(input) + } + input.value = "" + this.hideDropdown(input.closest(".user-search-dropdown")) + input.focus() + } + + /** + * Setup share item button + * @param {Element} shareButton - Share button element + */ + setupShareItem(shareButton) { + // Prevent duplicate event listener attachment + if (shareButton.dataset.shareSetup === "true") { + return + } + shareButton.dataset.shareSetup = "true" + + shareButton.addEventListener("click", async () => { + await this.handleShareItem() + }) + } + + /** + * Handle share item action + */ + async handleShareItem() { + // Get the user emails from the selected users map + const inputId = `user-search-${this.itemUuid}` + const selectedUsers = this.selectedUsersMap[inputId] || [] + + // Create a map of user emails to their permission levels + const userPermissions = {} + for (const user of selectedUsers) { + userPermissions[user.email] = + user.permission_level || window.PermissionLevels.VIEWER + } + + const userEmails = selectedUsers.map((u) => u.email).join(",") + + const formData = { + "user-search": userEmails, + user_permissions: JSON.stringify(userPermissions), + } + + // Add notify_users and notify_message if present + const notifyCheckbox = document.getElementById( + `notify-users-checkbox-${this.itemUuid}`, + ) + if (notifyCheckbox?.checked) { + formData.notify_users = "1" + const messageTextarea = document.getElementById( + `notify-message-textarea-${this.itemUuid}`, + ) + if (messageTextarea?.value.trim()) { + formData.notify_message = messageTextarea.value.trim() + } + } + + // Handle pending removals + if (this.pendingRemovals.size > 0) { + formData.remove_users = JSON.stringify( + Array.from(this.pendingRemovals), + ) + } + + // Handle pending permission changes + if ( + this.pendingPermissionChanges && + this.pendingPermissionChanges.size > 0 + ) { + formData.permission_changes = JSON.stringify( + Array.from(this.pendingPermissionChanges.entries()), + ) + } + + try { + const response = await window.APIClient.post( + `/users/share-item/${this.itemType}/${this.itemUuid}/`, + formData, + ) + + if (response.success) { + // Show success message + this.showToast( + response.message || `${this.itemType} shared successfully!`, + "success", + ) + + // Close modal + this.closeModal(`shareModal-${this.itemUuid}`) + + // Refresh dataset list (await so modal/list re-init completes before user continues) + if ( + window.listRefreshManager && + typeof window.listRefreshManager.loadTable === "function" + ) { + try { + await window.listRefreshManager.loadTable() + } catch (refreshErr) { + console.error( + "ShareActionManager: list refresh failed after share", + refreshErr, + ) + } + } else { + // Fallback: reload the page if listRefreshManager is not available + console.warn( + "listRefreshManager not available, reloading page", + ) + window.location.reload() + } + } else { + // Show error message + const errorMessage = + response.error || + response.message || + `Error sharing ${this.itemType}` + this.showToast(errorMessage, "danger") + } + } catch (error) { + console.error(`Error sharing ${this.itemType}:`, error) + this.showToast( + `Error sharing ${this.itemType}. Please try again.`, + "danger", + ) + } + } + + /** + * Search users + * @param {string} query - Search query + * @param {Element} dropdown - Dropdown element + */ + async searchUsers(query, dropdown) { + // Cancel previous request if still pending + if (this.currentRequest) { + this.currentRequest.abort() + } + + try { + this.currentRequest = new AbortController() + // Get the search results from the API + const response = await window.APIClient.get( + `/users/share-item/${this.itemType}/${this.itemUuid}/`, + { q: query, limit: 10 }, + null, // No loading state for search + ) + // Render the HTML fragment using the template + if (response) { + const users = response || [] + const htmlResponse = await window.APIClient.post( + "/users/render-html/", + { + template: "users/components/user_search_results.html", + context: { + users: users, + }, + }, + null, + true, + ) // true = send as JSON + + // Pass the rendered HTML to displayResults + this.displayResults({ html: htmlResponse.html }, dropdown) + } else { + // No results found + this.displayResults({ html: null, results: [] }, dropdown) + } + } catch (error) { + if (error.name === "AbortError") { + return + } + console.error("Error searching users:", error) + await this.displayError(dropdown) + } finally { + this.currentRequest = null + } + } + + /** + * Display search results + * @param {Object} response - Response from server with html and results + * @param {Element} dropdown - Dropdown element + */ + displayResults(response, dropdown) { + const listGroup = dropdown.querySelector(".list-group") + + // Use server-rendered HTML + if (response.html) { + listGroup.innerHTML = response.html + } else if (response.results && response.results.length === 0) { + // Fallback for empty results + listGroup.innerHTML = + '
No users or groups found
' + } else { + // Fallback for error case + listGroup.innerHTML = + '
Error loading results
' + } + + this.showDropdown(dropdown) + } + + /** + * Display error in dropdown + * @param {Element} dropdown - Dropdown element + */ + async displayError(dropdown) { + const listGroup = dropdown.querySelector(".list-group") + if (!listGroup) return + await this.showMessageInTarget("Error loading users", listGroup, { + variant: "danger", + presentation: "alert", + templateContext: { dismissible: false }, + }) + this.showDropdown(dropdown) + } + + /** + * Navigate dropdown with keyboard + * @param {NodeList} items - Dropdown items + * @param {number} currentIndex - Current selected index + * @param {number} direction - Direction to navigate + */ + navigateDropdown(items, currentIndex, direction) { + window.UserSearchDropdown.navigateDropdown( + items, + currentIndex, + direction, + ) + } + + /** + * Select user from dropdown + * @param {Element} item - Selected item + * @param {Element} input - Search input + */ + selectUser(item, input) { + const userName = item.dataset.userName + const userEmail = item.dataset.userEmail + const userType = item.dataset.userType || "user" + const inputId = input.id + + if (!this.selectedUsersMap[inputId]) { + this.selectedUsersMap[inputId] = [] + } + + // Check if this user is already part of a selected group + if (userType === "user") { + const selectedGroups = this.selectedUsersMap[inputId].filter( + (u) => u.type === "group", + ) + for (const group of selectedGroups) { + // Check if this user is in the group by making an API call + this.checkUserInGroup(userEmail, group, input, userName) + return // Exit early, we'll handle the result in the callback + } + } + + if ( + !this.selectedUsersMap[inputId].some((u) => u.email === userEmail) + ) { + // Create user object with all available data + const userData = { + name: userName, + email: userEmail, + type: userType, + permission_level: window.PermissionLevels.VIEWER, // Default permission level + } + + // For groups, get member count from dataset attribute + if (userType === "group") { + const memberCount = item.dataset.memberCount + if (memberCount) { + userData.member_count = Number.parseInt(memberCount, 10) + } + } + + this.selectedUsersMap[inputId].push(userData) + this.renderChips(input) + } + + input.value = "" + this.hideDropdown(item.closest(".user-search-dropdown")) + input.focus() + } + + /** + * Check if user is in group + * @param {string} userEmail - User email + * @param {Object} group - Group object + * @param {Element} input - Search input + * @param {string} userName - User name + */ + async checkUserInGroup(userEmail, group, input, userName) { + try { + // Extract group UUID from group.email (format: "group:uuid") + const groupUuid = group.email.replace("group:", "") + + // Make API call to check if user is in the group + const data = await window.APIClient.get("/users/share-groups/", { + group_uuid: groupUuid, + }) + + if (data.success && data.members) { + const isUserInGroup = data.members.some( + (member) => member.email === userEmail, + ) + + if (isUserInGroup) { + // User is already in the group, show notification and don't add + this.showToast( + `${userName} is already part of the group "${group.name}"`, + "warning", + ) + input.value = "" + this.hideDropdown(input.closest(".user-search-dropdown")) + input.focus() + return + } + } + + // If we get here, user is not in the group, so add them normally + this._commitViewerSelection(input, userEmail, userName) + } catch (error) { + console.error("Error checking if user is in group:", error) + // If there's an error, just add the user normally + this._commitViewerSelection(input, userEmail, userName) + } + } + + /** + * Render user chips + * @param {Element} input - Search input + */ + async renderChips(input) { + const inputId = input.id + + // Try to find the chip container + let chipContainer = input + .closest(".user-search-input-container") + .querySelector(".selected-users-chips") + + // If not found, try to find it in the permissions section + if (!chipContainer) { + chipContainer = input + .closest("form") + .querySelector( + ".selected-users-permissions-section .selected-users-chips", + ) + } + + if (!chipContainer) { + console.warn("Chip container not found for input:", inputId) + return + } + + // Clear container + chipContainer.innerHTML = "" + + // If no users selected, toggle sections and return + if ( + !this.selectedUsersMap[inputId] || + this.selectedUsersMap[inputId].length === 0 + ) { + this.toggleModalSections(inputId) + return + } + + try { + // Filter permission levels based on item type + // For captures, only allow viewer permission + let allowedPermissionLevels = window.SHARE_PERMISSION_OPTIONS + if (this.itemType === "capture") { + allowedPermissionLevels = [window.PermissionLevels.VIEWER] + } + + // Request server to render using generic endpoint + const response = await window.APIClient.post( + "/users/render-html/", + { + template: "users/components/user_chips.html", + context: { + users: this.selectedUsersMap[inputId], + show_permission_select: true, + show_remove_button: true, + permission_levels: allowedPermissionLevels, + item_type: this.itemType, // Pass item type to template for conditional rendering + }, + }, + null, + true, + ) // true = send as JSON + + // Insert the server-rendered HTML + if (response.html) { + chipContainer.innerHTML = response.html + + // Add event handlers after rendering + this.attachChipEventHandlers(chipContainer, inputId, input) + } + } catch (error) { + this.logError?.(error, chipContainer) + await this.showMessageInTarget( + "Error loading users", + chipContainer, + { + variant: "danger", + presentation: "inline", + }, + ) + } + + // Toggle notify/message and users-with-access sections + this.toggleModalSections(inputId) + } + + /** + * Attach event handlers to rendered chips + * @param {Element} chipContainer - Chip container element + * @param {string} inputId - Input ID + * @param {Element} input - Search input element + */ + attachChipEventHandlers(chipContainer, inputId, input) { + // Add click handlers for removal buttons + const removeButtons = chipContainer.querySelectorAll(".remove-chip") + for (const removeBtn of removeButtons) { + const userEmail = removeBtn.closest(".user-chip").dataset.userEmail + removeBtn.onclick = () => { + this.selectedUsersMap[inputId] = this.selectedUsersMap[ + inputId + ].filter((u) => u.email !== userEmail) + this.renderChips(input) + } + } + + // Add change handlers for permission selects + const permissionSelects = + chipContainer.querySelectorAll(".permission-select") + for (const permissionSelect of permissionSelects) { + permissionSelect.onchange = (e) => { + const userEmail = e.target.dataset.userEmail + const userIndex = this.selectedUsersMap[inputId].findIndex( + (u) => u.email === userEmail, + ) + if (userIndex !== -1) { + this.selectedUsersMap[inputId][userIndex].permission_level = + e.target.value + } + } + } + } + + /** + * Toggle modal sections based on selections + * @param {string} inputId - Input ID + */ + toggleModalSections(inputId) { + const itemUuid = inputId.replace("user-search-", "") + const notifySection = document.getElementById( + `notify-message-section-${itemUuid}`, + ) + const usersWithAccessSection = document.getElementById( + `users-with-access-section-${itemUuid}`, + ) + const saveBtn = document.getElementById(`share-item-btn-${itemUuid}`) + const modalDivider = document.querySelector( + `#shareModal-${itemUuid} .modal-divider`, + ) + + if ( + this.selectedUsersMap[inputId] && + this.selectedUsersMap[inputId].length > 0 + ) { + // Show notify section, hide users with access section + if (notifySection) notifySection.classList.remove("d-none") + if (usersWithAccessSection) + usersWithAccessSection.classList.add("d-none") + if (modalDivider) modalDivider.classList.add("d-none") + + // Clear pending removals when adding new users + this.pendingRemovals.clear() + + // Reset all dropdown buttons to their original state + const modal = document.getElementById(`shareModal-${itemUuid}`) + if (modal) { + for (const button of modal.querySelectorAll( + ".btn-icon-dropdown", + )) { + button.innerHTML = + '' + button.classList.remove("btn-outline-danger") + button.classList.add("btn-light") + button.disabled = false + } + } + + // Change button text to "Share" + if (saveBtn) saveBtn.textContent = "Share" + + // Setup notify checkbox functionality + setTimeout(() => { + this.setupNotifyCheckbox(itemUuid) + }, 10) + } else { + // Hide notify section, show users with access section + if (notifySection) notifySection.classList.add("d-none") + if (usersWithAccessSection) + usersWithAccessSection.classList.remove("d-none") + if (modalDivider) modalDivider.classList.remove("d-none") + + // Change button text back to "Save" + if (saveBtn) saveBtn.textContent = "Save" + } + + // Update save button state + this.updateSaveButtonState(itemUuid) + } + + /** + * Update save button state + * @param {string} itemUuid - Item UUID + */ + updateSaveButtonState(itemUuid) { + const inputId = `user-search-${itemUuid}` + const selectedUsers = this.selectedUsersMap[inputId] || [] + const hasSelectedUsers = selectedUsers.length > 0 + const hasPendingRemovals = this.pendingRemovals.size > 0 + const hasPendingPermissionChanges = + this.pendingPermissionChanges && + this.pendingPermissionChanges.size > 0 + + const saveBtn = document.getElementById(`share-item-btn-${itemUuid}`) + const pendingMessage = document.getElementById( + `pending-changes-message-${itemUuid}`, + ) + + // Update save button + if (saveBtn) { + if ( + hasSelectedUsers || + hasPendingRemovals || + hasPendingPermissionChanges + ) { + saveBtn.disabled = false + } else { + saveBtn.disabled = true + } + } + + // Update pending message + if (pendingMessage) { + if (hasPendingRemovals || hasPendingPermissionChanges) { + pendingMessage.classList.remove("d-none") + } else { + pendingMessage.classList.add("d-none") + } + } + } + + /** + * Setup remove user buttons + */ + setupRemoveUserButtons() { + // Find the specific modal for this item + const modal = document.getElementById(`shareModal-${this.itemUuid}`) + if (!modal) { + console.error( + `Modal not found for ${this.itemType}: ${this.itemUuid}`, + ) + return + } + + // Prevent duplicate event listener attachment + if (modal.dataset.removeButtonsSetup === "true") { + return + } + modal.dataset.removeButtonsSetup = "true" + + // Setup permission change buttons for this specific modal only + modal.addEventListener("click", async (e) => { + if (e.target.closest(".permission-change-btn")) { + await this.handlePermissionChange(e) + } + }) + } + + /** + * Handle permission change + * @param {Event} e - Click event + */ + async handlePermissionChange(e) { + const button = e.target.closest(".permission-change-btn") + const userEmail = button.dataset.userEmail + const userName = button.dataset.userName + const itemUuid = button.dataset.itemUuid + const itemType = button.dataset.itemType + const permissionLevel = button.dataset.permissionLevel + + if (!userEmail || !itemUuid || !itemType || !permissionLevel) { + console.error( + "Missing user email, item UUID, item type, or permission level", + ) + return + } + + // Update the dropdown button text and icon to reflect the change + const dropdown = button.closest(".dropdown") + const dropdownButton = dropdown.querySelector(".access-level-dropdown") + const currentPermission = dropdownButton.getAttribute( + "data-current-permission", + ) + + // Don't do anything if selecting the same permission + if (permissionLevel === currentPermission) { + return + } + + // Update the dropdown button text and icon + const iconClass = this.permissions.getPermissionIcon(permissionLevel) + dropdownButton.innerHTML = `${permissionLevel.charAt(0).toUpperCase() + permissionLevel.slice(1)}` + dropdownButton.setAttribute("data-current-permission", permissionLevel) + + // Update checkmarks in dropdown menu + this.updateDropdownMenu(dropdown, button) + + // Update user text for removal case + if (permissionLevel === "remove") { + this.markUserForRemoval(userEmail, userName) + } else { + this.clearUserRemovalMarking(userEmail) + } + + // Handle permission level change + if (permissionLevel !== "remove") { + this.handlePermissionLevelChange( + userEmail, + userName, + itemUuid, + itemType, + permissionLevel, + ) + } + + // Update save button state + this.updateSaveButtonState(itemUuid) + + // Close the dropdown + const bsDropdown = bootstrap.Dropdown.getInstance(dropdownButton) + if (bsDropdown) { + bsDropdown.hide() + } + } + + /** + * Update dropdown menu + * @param {Element} dropdown - Dropdown element + * @param {Element} clickedButton - Clicked button + */ + updateDropdownMenu(dropdown, clickedButton) { + const dropdownMenu = dropdown.querySelector(".access-level-menu") + const allPermissionBtns = dropdownMenu.querySelectorAll( + ".permission-change-btn", + ) + + // Remove selected class and checkmarks from all buttons + for (const btn of allPermissionBtns) { + btn.classList.remove("selected") + const checkmark = btn.querySelector(".bi-check") + if (checkmark) { + checkmark.remove() + } + } + + // Add selected class and checkmark to the clicked button + clickedButton.classList.add("selected") + const checkmark = document.createElement("i") + checkmark.className = "bi bi-check ms-auto" + clickedButton.appendChild(checkmark) + } + + /** + * Mark user for removal + * @param {string} userEmail - User email + * @param {string} userName - User name + */ + markUserForRemoval(userEmail, userName) { + this.pendingRemovals.add(userEmail) + + // Remove from permission changes if it was there + if (this.pendingPermissionChanges?.has(userEmail)) { + this.pendingPermissionChanges.delete(userEmail) + } + + // Update user text styling + const userRow = document + .querySelector(`[data-user-email="${userEmail}"]`) + .closest("tr") + const userNameElement = userRow.querySelector("h5") + if (userNameElement) { + userNameElement.style.textDecoration = "line-through" + userNameElement.style.opacity = "0.6" + } + } + + /** + * Clear user removal marking + * @param {string} userEmail - User email + */ + clearUserRemovalMarking(userEmail) { + this.pendingRemovals.delete(userEmail) + + // Clear any existing text decoration or opacity + const userRow = document + .querySelector(`[data-user-email="${userEmail}"]`) + .closest("tr") + const userNameElement = userRow.querySelector("h5") + if (userNameElement) { + userNameElement.style.textDecoration = "none" + userNameElement.style.opacity = "1" + } + } + + /** + * Handle permission level change + * @param {string} userEmail - User email + * @param {string} userName - User name + * @param {string} itemUuid - Item UUID + * @param {string} itemType - Item type + * @param {string} permissionLevel - New permission level + */ + handlePermissionLevelChange( + userEmail, + userName, + itemUuid, + itemType, + permissionLevel, + ) { + // Store the permission change + if (!this.pendingPermissionChanges) { + this.pendingPermissionChanges = new Map() + } + this.pendingPermissionChanges.set(userEmail, { + userName: userName, + itemUuid: itemUuid, + itemType: itemType, + permissionLevel: permissionLevel, + }) + } + + /** + * Get dropdown for input + * @param {Element} input - Input element + * @returns {Element|null} Dropdown element + */ + getDropdownForInput(input) { + return window.UserSearchDropdown.getDropdownForInput(input, { + itemUuid: this.itemUuid, + }) + } + + /** + * Show dropdown + * @param {Element} dropdown - Dropdown element + */ + showDropdown(dropdown) { + window.UserSearchDropdown.showDropdown(dropdown) + } + + /** + * Hide dropdown + * @param {Element} dropdown - Dropdown element + */ + hideDropdown(dropdown) { + window.UserSearchDropdown.hideDropdown(dropdown) + } + + /** + * Clear selections + */ + clearSelections() { + // Clear selected users + this.selectedUsersMap = {} + + // Clear pending removals + this.pendingRemovals.clear() + + // Clear pending permission changes + if (this.pendingPermissionChanges) { + this.pendingPermissionChanges.clear() + } + + // Reset all dropdown buttons to their original state + for (const button of document.querySelectorAll(".btn-icon-dropdown")) { + button.innerHTML = + '' + button.classList.remove("btn-outline-danger") + button.classList.add("btn-light") + button.disabled = false + } + + // Reset save button state for all modals + for (const btn of document.querySelectorAll( + '[id^="share-item-btn-"]', + )) { + btn.disabled = true + btn.textContent = "Save" + } + + // Hide pending changes messages + for (const msg of document.querySelectorAll( + '[id^="pending-changes-message-"]', + )) { + msg.classList.add("d-none") + } + + // Clear chips + for (const container of document.querySelectorAll( + ".selected-users-chips", + )) { + container.innerHTML = "" + } + + // Hide notify sections and show users-with-access sections + for (const section of document.querySelectorAll( + '[id^="notify-message-section-"]', + )) { + section.classList.add("d-none") + } + for (const section of document.querySelectorAll( + '[id^="users-with-access-section-"]', + )) { + section.classList.remove("d-none") + } + + // Show modal dividers + for (const divider of document.querySelectorAll(".modal-divider")) { + divider.classList.remove("d-none") + } + + // Clear search inputs + for (const input of document.querySelectorAll(".user-search-input")) { + input.value = "" + } + + // Hide dropdowns and clear their content + for (const dropdown of document.querySelectorAll( + ".user-search-dropdown", + )) { + dropdown.classList.add("d-none") + const listGroup = dropdown.querySelector(".list-group") + if (listGroup) { + listGroup.innerHTML = "" + } + } + + // Reset notify checkboxes and textareas + for (const checkbox of document.querySelectorAll( + '[id^="notify-users-checkbox-"]', + )) { + checkbox.checked = true + const itemUuid = checkbox.id.replace("notify-users-checkbox-", "") + this.setupNotifyCheckbox(itemUuid) + } + for (const textarea of document.querySelectorAll( + '[id^="notify-message-textarea-"]', + )) { + textarea.value = "" + } + } + + /** + * Get permission button text with icon (legacy method) + * @param {string} permissionLevel - Permission level + * @returns {string} Button text with icon + */ + getPermissionButtonText(permissionLevel) { + // Handle undefined/null permission levels + const level = + !permissionLevel || typeof permissionLevel !== "string" + ? window.PermissionLevels.VIEWER + : permissionLevel + + const iconClass = + this.permissions?.getPermissionIcon(level) || "bi-question-circle" + const displayText = level.charAt(0).toUpperCase() + level.slice(1) + return `${displayText}` + } + + /** + * Setup notify checkbox functionality + * @param {string} itemUuid - Item UUID + */ + setupNotifyCheckbox(itemUuid) { + const notifyCheckbox = document.getElementById( + `notify-users-checkbox-${itemUuid}`, + ) + const textareaContainer = document.getElementById( + `notify-message-textarea-container-${itemUuid}`, + ) + + if (!notifyCheckbox || !textareaContainer) { + // Don't log error - this is expected for modals without notify functionality + return + } + + function toggleTextarea() { + if (notifyCheckbox.checked) { + textareaContainer.classList.add("show") + } else { + textareaContainer.classList.remove("show") + } + } + + // Remove any existing event listeners to prevent duplicates + notifyCheckbox.removeEventListener("change", toggleTextarea) + notifyCheckbox.addEventListener("change", toggleTextarea) + + // Call toggleTextarea immediately to set initial state + toggleTextarea() + } +} // Make class available globally -window.ShareActionManager = ShareActionManager; +window.ShareActionManager = ShareActionManager // Export for ES6 modules (Jest testing) - only if in module context if (typeof module !== "undefined" && module.exports) { - module.exports = { ShareActionManager }; + module.exports = { ShareActionManager } } diff --git a/gateway/sds_gateway/static/js/actions/VersioningActionManager.js b/gateway/sds_gateway/static/js/actions/VersioningActionManager.js index 234109269..89c7a6a6d 100644 --- a/gateway/sds_gateway/static/js/actions/VersioningActionManager.js +++ b/gateway/sds_gateway/static/js/actions/VersioningActionManager.js @@ -3,173 +3,183 @@ * Handles version creation and managing dataset versions */ class VersioningActionManager extends ModalManager { - /** - * Initialize versioning action manager - * @param {Object} config - Configuration object - */ - constructor(config) { - super(); - this.permissions = config.permissions; - this.datasetUuid = config.datasetUuid; - this.initializeEventListeners(); - this.modalId = `versioningModal-${this.datasetUuid}`; - } + /** + * Initialize versioning action manager + * @param {Object} config - Configuration object + */ + constructor(config) { + super() + this.permissions = config.permissions + this.datasetUuid = config.datasetUuid + this.initializeEventListeners() + this.modalId = `versioningModal-${this.datasetUuid}` + } - /** - * Initialize event listeners - */ - initializeEventListeners() { - // Initialize version creation button - this.initializeVersionCreationButton(); - this.initializeCopySharedUsersCheckbox(); - } + /** + * Initialize event listeners + */ + initializeEventListeners() { + // Initialize version creation button + this.initializeVersionCreationButton() + this.initializeCopySharedUsersCheckbox() + } - initializeVersionCreationButton() { - const versionCreationButton = document.getElementById( - `createVersionBtn-${this.datasetUuid}`, - ); - const copySharedUsersCheckbox = document.getElementById( - `copySharedUsers-${this.datasetUuid}`, - ); - if (versionCreationButton) { - // Prevent duplicate event listeners - if (versionCreationButton.dataset.versionSetup === "true") { - return; - } + initializeVersionCreationButton() { + const versionCreationButton = document.getElementById( + `createVersionBtn-${this.datasetUuid}`, + ) + const copySharedUsersCheckbox = document.getElementById( + `copySharedUsers-${this.datasetUuid}`, + ) + if (versionCreationButton) { + // Prevent duplicate event listeners + if (versionCreationButton.dataset.versionSetup === "true") { + return + } - versionCreationButton.dataset.versionSetup = "true"; - versionCreationButton.addEventListener("click", (event) => - this.handleVersionCreation( - event, - versionCreationButton, - copySharedUsersCheckbox, - ), - ); - } - } + versionCreationButton.dataset.versionSetup = "true" + versionCreationButton.addEventListener("click", (event) => + this.handleVersionCreation( + event, + versionCreationButton, + copySharedUsersCheckbox, + ), + ) + } + } - handleVersionCreation(event, versionCreationButton, copySharedUsersCheckbox) { - event.preventDefault(); - event.stopPropagation(); + handleVersionCreation( + event, + versionCreationButton, + copySharedUsersCheckbox, + ) { + event.preventDefault() + event.stopPropagation() - // Prevent double-submission - if (versionCreationButton.dataset.processing === "true") { - return; - } + // Prevent double-submission + if (versionCreationButton.dataset.processing === "true") { + return + } - // Mark as processing - versionCreationButton.dataset.processing = "true"; + // Mark as processing + versionCreationButton.dataset.processing = "true" - // show loading state - void ModalManager.showModalLoading(this.modalId); + // show loading state + void ModalManager.showModalLoading(this.modalId) - // disable button - versionCreationButton.disabled = true; + // disable button + versionCreationButton.disabled = true - // run API call to create a new version of the dataset - window.APIClient.post("/users/dataset-versioning/", { - dataset_uuid: this.datasetUuid, - copy_shared_users: copySharedUsersCheckbox?.checked ?? false, - }) - .then((response) => { - if (response.success) { - const modalEl = document.getElementById(this.modalId); - const onHidden = async () => { - if (modalEl) { - modalEl.removeEventListener("hidden.bs.modal", onHidden); - } - void window.DOMUtils?.showMessage?.( - `Dataset version updated to v${response.version} successfully`, - { - variant: "success", - placement: "toast", - presentation: "toast", - }, - ); - if ( - window.listRefreshManager && - typeof window.listRefreshManager.loadTable === "function" - ) { - try { - await window.listRefreshManager.loadTable(); - } catch (refreshErr) { - console.error( - "VersioningActionManager: list refresh failed after version create", - refreshErr, - ); - } - } else { - console.warn("listRefreshManager not available, reloading page"); - window.location.reload(); - } - }; - if (modalEl) { - modalEl.addEventListener("hidden.bs.modal", onHidden); - } - this.closeModal(this.modalId); - if (!modalEl) { - onHidden(); - } - } else { - // show error message and error message from response - void window.DOMUtils?.showMessage?.( - response.error || "Failed to create dataset version", - { - variant: "danger", - placement: "toast", - presentation: "toast", - }, - ); - } - }) - .catch((error) => { - // show error message and error message from error - void window.DOMUtils?.showMessage?.( - error.message || "Failed to create dataset version", - { - variant: "danger", - placement: "toast", - presentation: "toast", - }, - ); - }) - .finally(() => { - // Re-enable button and clear processing flag - versionCreationButton.disabled = false; - versionCreationButton.dataset.processing = "false"; - }); - } + // run API call to create a new version of the dataset + window.APIClient.post("/users/dataset-versioning/", { + dataset_uuid: this.datasetUuid, + copy_shared_users: copySharedUsersCheckbox?.checked ?? false, + }) + .then((response) => { + if (response.success) { + const modalEl = document.getElementById(this.modalId) + const onHidden = async () => { + if (modalEl) { + modalEl.removeEventListener( + "hidden.bs.modal", + onHidden, + ) + } + void window.DOMUtils?.showMessage?.( + `Dataset version updated to v${response.version} successfully`, + { + variant: "success", + placement: "toast", + presentation: "toast", + }, + ) + if ( + window.listRefreshManager && + typeof window.listRefreshManager.loadTable === + "function" + ) { + try { + await window.listRefreshManager.loadTable() + } catch (refreshErr) { + console.error( + "VersioningActionManager: list refresh failed after version create", + refreshErr, + ) + } + } else { + console.warn( + "listRefreshManager not available, reloading page", + ) + window.location.reload() + } + } + if (modalEl) { + modalEl.addEventListener("hidden.bs.modal", onHidden) + } + this.closeModal(this.modalId) + if (!modalEl) { + onHidden() + } + } else { + // show error message and error message from response + void window.DOMUtils?.showMessage?.( + response.error || "Failed to create dataset version", + { + variant: "danger", + placement: "toast", + presentation: "toast", + }, + ) + } + }) + .catch((error) => { + // show error message and error message from error + void window.DOMUtils?.showMessage?.( + error.message || "Failed to create dataset version", + { + variant: "danger", + placement: "toast", + presentation: "toast", + }, + ) + }) + .finally(() => { + // Re-enable button and clear processing flag + versionCreationButton.disabled = false + versionCreationButton.dataset.processing = "false" + }) + } - initializeCopySharedUsersCheckbox() { - const copySharedUsersCheckbox = document.getElementById( - `copySharedUsers-${this.datasetUuid}`, - ); - if (copySharedUsersCheckbox) { - copySharedUsersCheckbox.addEventListener("change", (event) => - this.showCopySharedUsersWarning(event), - ); - } - } + initializeCopySharedUsersCheckbox() { + const copySharedUsersCheckbox = document.getElementById( + `copySharedUsers-${this.datasetUuid}`, + ) + if (copySharedUsersCheckbox) { + copySharedUsersCheckbox.addEventListener("change", (event) => + this.showCopySharedUsersWarning(event), + ) + } + } - showCopySharedUsersWarning(event) { - const copySharedUsersWarning = document.getElementById( - `copySharedUsersWarning-${this.datasetUuid}`, - ); + showCopySharedUsersWarning(event) { + const copySharedUsersWarning = document.getElementById( + `copySharedUsersWarning-${this.datasetUuid}`, + ) - if (copySharedUsersWarning) { - if (event.target.checked) { - copySharedUsersWarning.classList.remove("display-none"); - } else { - copySharedUsersWarning.classList.add("display-none"); - } - } - } + if (copySharedUsersWarning) { + if (event.target.checked) { + copySharedUsersWarning.classList.remove("display-none") + } else { + copySharedUsersWarning.classList.add("display-none") + } + } + } } // Make class available globally -window.VersioningActionManager = VersioningActionManager; +window.VersioningActionManager = VersioningActionManager // Export for ES6 modules (Jest testing) - only if in module context if (typeof module !== "undefined" && module.exports) { - module.exports = { VersioningActionManager }; + module.exports = { VersioningActionManager } } diff --git a/gateway/sds_gateway/static/js/actions/__tests__/DetailsActionManager.test.js b/gateway/sds_gateway/static/js/actions/__tests__/DetailsActionManager.test.js index 87087fac0..63acee51a 100644 --- a/gateway/sds_gateway/static/js/actions/__tests__/DetailsActionManager.test.js +++ b/gateway/sds_gateway/static/js/actions/__tests__/DetailsActionManager.test.js @@ -2,34 +2,36 @@ * Jest tests for DetailsActionManager */ -import { DetailsActionManager } from "../DetailsActionManager.js"; +import { DetailsActionManager } from "../DetailsActionManager.js" describe("DetailsActionManager", () => { - beforeEach(() => { - jest.clearAllMocks(); - global.window.DOMUtils = { - renderContent: jest.fn().mockResolvedValue(true), - }; - global.window.bootstrap = { - Tooltip: jest.fn(function MockTooltip() { - this.dispose = jest.fn(); - }), - }; - global.window.bootstrap.Tooltip.getInstance = jest.fn(() => null); - global.navigator.clipboard = { writeText: jest.fn().mockResolvedValue(undefined) }; - }); + beforeEach(() => { + jest.clearAllMocks() + global.window.DOMUtils = { + renderContent: jest.fn().mockResolvedValue(true), + } + global.window.bootstrap = { + Tooltip: jest.fn(function MockTooltip() { + this.dispose = jest.fn() + }), + } + global.window.bootstrap.Tooltip.getInstance = jest.fn(() => null) + global.navigator.clipboard = { + writeText: jest.fn().mockResolvedValue(undefined), + } + }) - test("attachUuidCopyButton wires click handler", () => { - const btn = document.createElement("button"); - btn.className = "copy-uuid-btn"; - const modal = document.createElement("div"); - modal.appendChild(btn); - document.body.appendChild(modal); + test("attachUuidCopyButton wires click handler", () => { + const btn = document.createElement("button") + btn.className = "copy-uuid-btn" + const modal = document.createElement("div") + modal.appendChild(btn) + document.body.appendChild(modal) - DetailsActionManager.attachUuidCopyButton(modal, "u1"); - modal.querySelector(".copy-uuid-btn").click(); - expect(global.navigator.clipboard.writeText).toHaveBeenCalledWith("u1"); + DetailsActionManager.attachUuidCopyButton(modal, "u1") + modal.querySelector(".copy-uuid-btn").click() + expect(global.navigator.clipboard.writeText).toHaveBeenCalledWith("u1") - document.body.removeChild(modal); - }); -}); + document.body.removeChild(modal) + }) +}) diff --git a/gateway/sds_gateway/static/js/actions/__tests__/DownloadActionManager.test.js b/gateway/sds_gateway/static/js/actions/__tests__/DownloadActionManager.test.js index 360f1de62..e0e1a2b1f 100644 --- a/gateway/sds_gateway/static/js/actions/__tests__/DownloadActionManager.test.js +++ b/gateway/sds_gateway/static/js/actions/__tests__/DownloadActionManager.test.js @@ -4,606 +4,622 @@ */ // Import the DownloadActionManager class -import { ModalManager } from "../../core/ModalManager.js"; -import { DownloadActionManager } from "../DownloadActionManager.js"; +import { ModalManager } from "../../core/ModalManager.js" +import { DownloadActionManager } from "../DownloadActionManager.js" const { - setupDownloadActionTestEnvironment, -} = require("../../__tests__/helpers/actionTestMocks.js"); + setupDownloadActionTestEnvironment, +} = require("../../__tests__/helpers/actionTestMocks.js") describe("DownloadActionManager", () => { - let downloadManager; - let mockButton; - let mockModal; - let mockPermissions; - - beforeEach(() => { - ({ mockPermissions, mockButton, mockModal } = - setupDownloadActionTestEnvironment()); - }); - - describe("Initialization", () => { - test("should initialize with permissions", () => { - downloadManager = new DownloadActionManager({ - permissions: mockPermissions, - }); - - expect(downloadManager.permissions).toBeDefined(); - expect(downloadManager.permissions.canDownload()).toBe(true); - }); - - test("should setup event listeners on initialization", () => { - downloadManager = new DownloadActionManager({ - permissions: mockPermissions, - }); - - expect(mockButton.addEventListener).toHaveBeenCalled(); - }); - }); - - describe("Dataset Download Functionality", () => { - let downloadManager; - let mockAPIClient; - let mockButton; - let clonedConfirmBtn; - - beforeEach(() => { - mockAPIClient = { - post: jest.fn(), - }; - window.APIClient = mockAPIClient; - - window.DOMUtils = { - openModal: jest.fn(), - closeModal: jest.fn(), - renderLoading: jest.fn().mockResolvedValue(true), - renderContent: jest.fn().mockResolvedValue(true), - showMessage: jest.fn(), - }; - - mockButton = { - innerHTML: "Download", - disabled: false, - addEventListener: jest.fn(), - dataset: { downloadSetup: "false" }, - getAttribute: jest.fn((attr) => { - if (attr === "data-item-uuid") return "test-item-uuid"; - if (attr === "data-item-type") return "dataset"; - return null; - }), - }; - - document.querySelectorAll = jest.fn((selector) => { - if (selector === ".web-download-btn") return [mockButton]; - return []; - }); - - document.getElementById = jest.fn((id) => { - if (id.startsWith("webDownloadDatasetName-")) { - return { textContent: "" }; - } - if (id.startsWith("confirmWebDownloadBtn-")) { - return { - cloneNode: jest.fn(() => { - clonedConfirmBtn = { - parentNode: { replaceChild: jest.fn() }, - onclick: null, - }; - return clonedConfirmBtn; - }), - parentNode: { replaceChild: jest.fn() }, - }; - } - return null; - }); - - downloadManager = new DownloadActionManager({ - permissions: { canDownload: () => true }, - }); - downloadManager.showToast = jest.fn(); - }); - - test("should initialize web download buttons", () => { - downloadManager.initializeWebDownloadButtons(); - - expect(document.querySelectorAll).toHaveBeenCalledWith( - ".web-download-btn", - ); - expect(mockButton.addEventListener).toHaveBeenCalledWith( - "click", - expect.any(Function), - ); - }); - - test("should successfully request download and show success message", async () => { - mockAPIClient.post.mockResolvedValue({ - success: true, - message: "Download request submitted", - }); - - await downloadManager.initializeWebDownloadModal( - "test-item-uuid", - "dataset", - mockButton, - ); - - downloadManager.prepareWebDownloadModal( - { - id: "webDownloadModal-test-item-uuid", - getAttribute: (attr) => - attr === "data-item-type" ? "dataset" : null, - }, - mockButton, - ); - - // Simulate confirm button click using the same clone the code assigned onclick to - if (clonedConfirmBtn && typeof clonedConfirmBtn.onclick === "function") { - await clonedConfirmBtn.onclick(); - } - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(mockAPIClient.post).toHaveBeenCalledWith( - "/users/download-item/dataset/test-item-uuid/", - {}, - null, - false, - ); - expect(window.DOMUtils.renderContent).toHaveBeenCalledWith( - mockButton, - expect.objectContaining({ - icon: "check-circle", - color: "success", - }), - ); - expect(downloadManager.showToast).toHaveBeenCalledWith( - expect.stringContaining("Download request submitted"), - "success", - ); - }); - - test("should prevent duplicate event listener attachment", () => { - // Create a new button with downloadSetup already set - const mockButtonWithSetup = { - dataset: { downloadSetup: "true" }, - addEventListener: jest.fn(), - getAttribute: jest.fn(), - }; - - // Mock document.querySelectorAll to return the button with setup already done - document.querySelectorAll.mockReturnValue([mockButtonWithSetup]); - - // Clear previous calls - mockButtonWithSetup.addEventListener.mockClear(); - - downloadManager.initializeWebDownloadButtons(); - - expect(mockButtonWithSetup.addEventListener).not.toHaveBeenCalled(); - }); - - test("should show permission error when download not allowed", async () => { - // Create permissions that deny download - const denyPermissions = { - canDownload: jest.fn(() => false), - }; - - const testDownloadManager = new DownloadActionManager({ - permissions: denyPermissions, - }); - - // Test the showToast method directly - testDownloadManager.showToast( - "You don't have permission to download this dataset", - "warning", - ); - - // showToast calls DOMUtils.showMessage - expect(window.DOMUtils.showMessage).toHaveBeenCalledWith( - "You don't have permission to download this dataset", - expect.objectContaining({ - variant: "warning", - placement: "toast", - presentation: "toast", - }), - ); - }); - }); - - describe("Capture Download Functionality", () => { - beforeEach(() => { - window.DOMUtils = { - ...global.window.DOMUtils, - openModal: jest.fn(), - closeModal: jest.fn(), - renderLoading: jest.fn().mockResolvedValue(true), - renderContent: jest.fn().mockResolvedValue(true), - showMessage: jest.fn(), - }; - downloadManager = new DownloadActionManager({ - permissions: mockPermissions, - }); - }); - - test("should configure temporal slider when web download modal is shown for capture", () => { - const spy = jest - .spyOn(downloadManager, "setTemporalSliderAttrs") - .mockImplementation(() => {}); - jest - .spyOn(downloadManager, "wireWebDownloadConfirm") - .mockImplementation(() => {}); - - document.getElementById = jest.fn((id) => { - if (id.startsWith("confirmWebDownloadBtn-")) { - return { - cloneNode: jest.fn(() => ({ - parentNode: { replaceChild: jest.fn() }, - onclick: null, - })), - parentNode: { replaceChild: jest.fn() }, - }; - } - return null; - }); - - const captureBtn = { - innerHTML: "Download", - disabled: false, - dataset: {}, - getAttribute: jest.fn((attr) => { - if (attr === "data-item-uuid") return "test-capture-uuid"; - if (attr === "data-item-type") return "capture"; - return null; - }), - }; - - const modal = { - id: "webDownloadModal-test-capture-uuid", - getAttribute: (attr) => - attr === "data-item-type" ? "capture" : null, - }; - - downloadManager.prepareWebDownloadModal(modal, captureBtn); - - expect(spy).toHaveBeenCalledWith( - "webDownloadModal-test-capture-uuid", - modal, - "test-capture-uuid", - ); - spy.mockRestore(); - }); - - test("should show permission error for capture download", async () => { - // Create permissions that deny download - const denyPermissions = { - canDownload: jest.fn(() => false), - }; - - const testDownloadManager = new DownloadActionManager({ - permissions: denyPermissions, - }); - - // Test the showToast method directly - testDownloadManager.showToast( - "You don't have permission to download this capture", - "warning", - ); - - // showToast calls DOMUtils.showMessage - expect(window.DOMUtils.showMessage).toHaveBeenCalledWith( - "You don't have permission to download this capture", - expect.objectContaining({ - variant: "warning", - placement: "toast", - presentation: "toast", - }), - ); - }); - }); - - describe("initializeCaptureDownloadSlider", () => { - const MODAL_ID = "webDownloadModal-test-uuid"; - let mockSliderEl; - let mockNoUiSliderCreate; - let mockSliderInstance; - - function stubEl() { - return { - textContent: "", - value: "", - dataset: {}, - classList: { add: jest.fn(), remove: jest.fn() }, - disabled: false, - addEventListener: jest.fn(), - }; - } - - /** Modal root with querySelector("#id") like the real DOM */ - function mockWebDownloadModal(elementMap) { - const map = elementMap || {}; - return { - dataset: {}, - querySelector: jest.fn((sel) => { - const id = sel.startsWith("#") ? sel.slice(1) : sel; - if (Object.prototype.hasOwnProperty.call(map, id)) { - return map[id]; - } - return stubEl(); - }), - }; - } - - beforeEach(() => { - downloadManager = new DownloadActionManager({ - permissions: mockPermissions, - }); - mockSliderInstance = { - on: jest.fn(), - destroy: jest.fn(), - set: jest.fn(), - }; - mockSliderEl = { - noUiSlider: null, - dataset: {}, - }; - mockNoUiSliderCreate = jest.fn(() => { - mockSliderEl.noUiSlider = mockSliderInstance; - }); - // Slider path touches formatFileSize on totalSizeLabel - global.window.DOMUtils = { - ...global.window.DOMUtils, - formatFileSize: jest.fn((n) => `${n} B`), - }; - }); - - test("should return early when modal root element is missing", () => { - document.getElementById = jest.fn(() => null); - global.noUiSlider = { create: mockNoUiSliderCreate }; - - downloadManager.initializeCaptureDownloadSlider( - MODAL_ID, - 10000, - 1000, - {}, - ); - expect(mockNoUiSliderCreate).not.toHaveBeenCalled(); - }); - - test("should return early when temporalFilterSlider element is missing", () => { - const modal = mockWebDownloadModal({ temporalFilterSlider: null }); - document.getElementById = jest.fn((id) => - id === MODAL_ID ? modal : null, - ); - global.noUiSlider = { create: mockNoUiSliderCreate }; - - downloadManager.initializeCaptureDownloadSlider( - MODAL_ID, - 10000, - 1000, - {}, - ); - expect(mockNoUiSliderCreate).not.toHaveBeenCalled(); - }); - - test("should return early when noUiSlider is undefined", () => { - const originalNoUiSlider = global.noUiSlider; - global.noUiSlider = undefined; - const modal = mockWebDownloadModal({ - temporalFilterSlider: mockSliderEl, - }); - document.getElementById = jest.fn((id) => - id === MODAL_ID ? modal : null, - ); - - expect(() => { - downloadManager.initializeCaptureDownloadSlider( - MODAL_ID, - 10000, - 1000, - {}, - ); - }).not.toThrow(); - - global.noUiSlider = originalNoUiSlider; - }); - - test("should create slider and set modal dataset and range hint when slider and noUiSlider exist", () => { - const rangeHintEl = { textContent: "" }; - const webDownloadModal = mockWebDownloadModal({ - temporalFilterSlider: mockSliderEl, - temporalRangeHint: rangeHintEl, - }); - document.getElementById = jest.fn((id) => - id === MODAL_ID ? webDownloadModal : null, - ); - global.noUiSlider = { create: mockNoUiSliderCreate }; - - downloadManager.initializeCaptureDownloadSlider(MODAL_ID, 5000, 500, { - dataFilesCount: 10, - totalFilesCount: 12, - totalSize: 1000000, - }); - - expect(mockNoUiSliderCreate).toHaveBeenCalledWith( - mockSliderEl, - expect.objectContaining({ - start: [0, 5000], - connect: true, - step: 500, - range: { min: 0, max: 5000 }, - }), - ); - expect(webDownloadModal.dataset.durationMs).toBe("5000"); - expect(webDownloadModal.dataset.fileCadenceMs).toBe("500"); - expect(rangeHintEl.textContent).toBe("0 – 5000 ms"); - }); - - test("should not create slider when durationMs is 0", () => { - const rangeHintEl = { textContent: "" }; - const modal = mockWebDownloadModal({ - temporalFilterSlider: mockSliderEl, - temporalRangeHint: rangeHintEl, - }); - document.getElementById = jest.fn((id) => - id === MODAL_ID ? modal : null, - ); - global.noUiSlider = { create: mockNoUiSliderCreate }; - - downloadManager.initializeCaptureDownloadSlider(MODAL_ID, 0, 1000, {}); - - expect(mockNoUiSliderCreate).not.toHaveBeenCalled(); - }); - }); - - describe("Web Download Modal", () => { - beforeEach(() => { - downloadManager = new DownloadActionManager({ - permissions: mockPermissions, - }); - }); - - test("openSDKDownloadModal calls openModal when modal element exists", () => { - const modalId = "sdkDownloadModal-ds-1"; - const modal = { id: modalId, addEventListener: jest.fn() }; - document.getElementById = jest.fn((id) => (id === modalId ? modal : null)); - const openSpy = jest - .spyOn(ModalManager.prototype, "openModal") - .mockImplementation(() => {}); - - downloadManager.openSDKDownloadModal("ds-1"); - - expect(openSpy).toHaveBeenCalledWith(modalId); - expect(modal.addEventListener).toHaveBeenCalledWith( - "shown.bs.modal", - expect.any(Function), - { once: true }, - ); - openSpy.mockRestore(); - }); - - test("should use ModalManager.openModal for opening modals", async () => { - const openSpy = jest - .spyOn(ModalManager.prototype, "openModal") - .mockImplementation(() => {}); - const modalId = "webDownloadModal-test-uuid"; - const confirmBtn = { - dataset: {}, - cloneNode: jest.fn(() => confirmBtn), - parentNode: { replaceChild: jest.fn() }, - onclick: null, - innerHTML: "", - disabled: false, - }; - document.getElementById = jest.fn((id) => { - if (id === modalId) { - return { id: modalId, addEventListener: jest.fn() }; - } - if (id === "webDownloadDatasetName-test-uuid") { - return { textContent: "" }; - } - if (id === "confirmWebDownloadBtn-test-uuid") { - return confirmBtn; - } - return null; - }); - - const btn = { - innerHTML: "Download", - disabled: false, - dataset: {}, - getAttribute: jest.fn((attr) => { - if (attr === "data-item-uuid") return "test-uuid"; - if (attr === "data-item-type") return "dataset"; - return null; - }), - }; - - await downloadManager.initializeWebDownloadModal( - "test-uuid", - "dataset", - btn, - ); - - expect(openSpy).toHaveBeenCalledWith( - modalId, - expect.objectContaining({ - trigger: btn, - downloadActionManager: downloadManager, - }), - ); - openSpy.mockRestore(); - }); - }); - - describe("Permission Handling", () => { - test("should check dataset download permissions", () => { - downloadManager = new DownloadActionManager({ - permissions: mockPermissions, - }); - - expect(downloadManager.permissions.canDownload()).toBe(true); - }); - - test("should check capture download permissions", () => { - const denyPermissions = { - canDownload: jest.fn(() => false), - }; - downloadManager = new DownloadActionManager({ - permissions: denyPermissions, - }); - - expect(downloadManager.permissions.canDownload()).toBe(false); - }); - - test("should default to true when no permissions specified", () => { - downloadManager = new DownloadActionManager({ - permissions: mockPermissions, - }); - - expect(downloadManager.permissions.canDownload()).toBe(true); - }); - }); - - describe("Error Handling", () => { - beforeEach(() => { - downloadManager = new DownloadActionManager({ - permissions: mockPermissions, - }); - }); - - test("should handle missing button elements", () => { - document.querySelectorAll.mockReturnValue([]); - - expect(() => { - downloadManager.initializeWebDownloadButtons(); - }).not.toThrow(); - }); - - test("should handle missing modal elements", () => { - expect(() => { - downloadManager.closeModal("test-modal"); - }).not.toThrow(); - }); - }); - - describe("Cleanup", () => { - beforeEach(() => { - downloadManager = new DownloadActionManager({ - permissions: mockPermissions, - }); - }); - - test("cleanup removes click listeners from web download buttons", () => { - const button = { - removeEventListener: jest.fn(), - }; - document.querySelectorAll = jest.fn(() => [button]); - - downloadManager.cleanup(); - - expect(button.removeEventListener).toHaveBeenCalledWith( - "click", - downloadManager.initializeWebDownloadButtons, - ); - }); - }); -}); + let downloadManager + let mockButton + let mockModal + let mockPermissions + + beforeEach(() => { + ;({ mockPermissions, mockButton, mockModal } = + setupDownloadActionTestEnvironment()) + }) + + describe("Initialization", () => { + test("should initialize with permissions", () => { + downloadManager = new DownloadActionManager({ + permissions: mockPermissions, + }) + + expect(downloadManager.permissions).toBeDefined() + expect(downloadManager.permissions.canDownload()).toBe(true) + }) + + test("should setup event listeners on initialization", () => { + downloadManager = new DownloadActionManager({ + permissions: mockPermissions, + }) + + expect(mockButton.addEventListener).toHaveBeenCalled() + }) + }) + + describe("Dataset Download Functionality", () => { + let downloadManager + let mockAPIClient + let mockButton + let clonedConfirmBtn + + beforeEach(() => { + mockAPIClient = { + post: jest.fn(), + } + window.APIClient = mockAPIClient + + window.DOMUtils = { + openModal: jest.fn(), + closeModal: jest.fn(), + renderLoading: jest.fn().mockResolvedValue(true), + renderContent: jest.fn().mockResolvedValue(true), + showMessage: jest.fn(), + } + + mockButton = { + innerHTML: "Download", + disabled: false, + addEventListener: jest.fn(), + dataset: { downloadSetup: "false" }, + getAttribute: jest.fn((attr) => { + if (attr === "data-item-uuid") return "test-item-uuid" + if (attr === "data-item-type") return "dataset" + return null + }), + } + + document.querySelectorAll = jest.fn((selector) => { + if (selector === ".web-download-btn") return [mockButton] + return [] + }) + + document.getElementById = jest.fn((id) => { + if (id.startsWith("webDownloadDatasetName-")) { + return { textContent: "" } + } + if (id.startsWith("confirmWebDownloadBtn-")) { + return { + cloneNode: jest.fn(() => { + clonedConfirmBtn = { + parentNode: { replaceChild: jest.fn() }, + onclick: null, + } + return clonedConfirmBtn + }), + parentNode: { replaceChild: jest.fn() }, + } + } + return null + }) + + downloadManager = new DownloadActionManager({ + permissions: { canDownload: () => true }, + }) + downloadManager.showToast = jest.fn() + }) + + test("should initialize web download buttons", () => { + downloadManager.initializeWebDownloadButtons() + + expect(document.querySelectorAll).toHaveBeenCalledWith( + ".web-download-btn", + ) + expect(mockButton.addEventListener).toHaveBeenCalledWith( + "click", + expect.any(Function), + ) + }) + + test("should successfully request download and show success message", async () => { + mockAPIClient.post.mockResolvedValue({ + success: true, + message: "Download request submitted", + }) + + await downloadManager.initializeWebDownloadModal( + "test-item-uuid", + "dataset", + mockButton, + ) + + downloadManager.prepareWebDownloadModal( + { + id: "webDownloadModal-test-item-uuid", + getAttribute: (attr) => + attr === "data-item-type" ? "dataset" : null, + }, + mockButton, + ) + + // Simulate confirm button click using the same clone the code assigned onclick to + if ( + clonedConfirmBtn && + typeof clonedConfirmBtn.onclick === "function" + ) { + await clonedConfirmBtn.onclick() + } + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(mockAPIClient.post).toHaveBeenCalledWith( + "/users/download-item/dataset/test-item-uuid/", + {}, + null, + false, + ) + expect(window.DOMUtils.renderContent).toHaveBeenCalledWith( + mockButton, + expect.objectContaining({ + icon: "check-circle", + color: "success", + }), + ) + expect(downloadManager.showToast).toHaveBeenCalledWith( + expect.stringContaining("Download request submitted"), + "success", + ) + }) + + test("should prevent duplicate event listener attachment", () => { + // Create a new button with downloadSetup already set + const mockButtonWithSetup = { + dataset: { downloadSetup: "true" }, + addEventListener: jest.fn(), + getAttribute: jest.fn(), + } + + // Mock document.querySelectorAll to return the button with setup already done + document.querySelectorAll.mockReturnValue([mockButtonWithSetup]) + + // Clear previous calls + mockButtonWithSetup.addEventListener.mockClear() + + downloadManager.initializeWebDownloadButtons() + + expect(mockButtonWithSetup.addEventListener).not.toHaveBeenCalled() + }) + + test("should show permission error when download not allowed", async () => { + // Create permissions that deny download + const denyPermissions = { + canDownload: jest.fn(() => false), + } + + const testDownloadManager = new DownloadActionManager({ + permissions: denyPermissions, + }) + + // Test the showToast method directly + testDownloadManager.showToast( + "You don't have permission to download this dataset", + "warning", + ) + + // showToast calls DOMUtils.showMessage + expect(window.DOMUtils.showMessage).toHaveBeenCalledWith( + "You don't have permission to download this dataset", + expect.objectContaining({ + variant: "warning", + placement: "toast", + presentation: "toast", + }), + ) + }) + }) + + describe("Capture Download Functionality", () => { + beforeEach(() => { + window.DOMUtils = { + ...global.window.DOMUtils, + openModal: jest.fn(), + closeModal: jest.fn(), + renderLoading: jest.fn().mockResolvedValue(true), + renderContent: jest.fn().mockResolvedValue(true), + showMessage: jest.fn(), + } + downloadManager = new DownloadActionManager({ + permissions: mockPermissions, + }) + }) + + test("should configure temporal slider when web download modal is shown for capture", () => { + const spy = jest + .spyOn(downloadManager, "setTemporalSliderAttrs") + .mockImplementation(() => {}) + jest.spyOn( + downloadManager, + "wireWebDownloadConfirm", + ).mockImplementation(() => {}) + + document.getElementById = jest.fn((id) => { + if (id.startsWith("confirmWebDownloadBtn-")) { + return { + cloneNode: jest.fn(() => ({ + parentNode: { replaceChild: jest.fn() }, + onclick: null, + })), + parentNode: { replaceChild: jest.fn() }, + } + } + return null + }) + + const captureBtn = { + innerHTML: "Download", + disabled: false, + dataset: {}, + getAttribute: jest.fn((attr) => { + if (attr === "data-item-uuid") return "test-capture-uuid" + if (attr === "data-item-type") return "capture" + return null + }), + } + + const modal = { + id: "webDownloadModal-test-capture-uuid", + getAttribute: (attr) => + attr === "data-item-type" ? "capture" : null, + } + + downloadManager.prepareWebDownloadModal(modal, captureBtn) + + expect(spy).toHaveBeenCalledWith( + "webDownloadModal-test-capture-uuid", + modal, + "test-capture-uuid", + ) + spy.mockRestore() + }) + + test("should show permission error for capture download", async () => { + // Create permissions that deny download + const denyPermissions = { + canDownload: jest.fn(() => false), + } + + const testDownloadManager = new DownloadActionManager({ + permissions: denyPermissions, + }) + + // Test the showToast method directly + testDownloadManager.showToast( + "You don't have permission to download this capture", + "warning", + ) + + // showToast calls DOMUtils.showMessage + expect(window.DOMUtils.showMessage).toHaveBeenCalledWith( + "You don't have permission to download this capture", + expect.objectContaining({ + variant: "warning", + placement: "toast", + presentation: "toast", + }), + ) + }) + }) + + describe("initializeCaptureDownloadSlider", () => { + const MODAL_ID = "webDownloadModal-test-uuid" + let mockSliderEl + let mockNoUiSliderCreate + let mockSliderInstance + + function stubEl() { + return { + textContent: "", + value: "", + dataset: {}, + classList: { add: jest.fn(), remove: jest.fn() }, + disabled: false, + addEventListener: jest.fn(), + } + } + + /** Modal root with querySelector("#id") like the real DOM */ + function mockWebDownloadModal(elementMap) { + const map = elementMap || {} + return { + dataset: {}, + querySelector: jest.fn((sel) => { + const id = sel.startsWith("#") ? sel.slice(1) : sel + if (Object.prototype.hasOwnProperty.call(map, id)) { + return map[id] + } + return stubEl() + }), + } + } + + beforeEach(() => { + downloadManager = new DownloadActionManager({ + permissions: mockPermissions, + }) + mockSliderInstance = { + on: jest.fn(), + destroy: jest.fn(), + set: jest.fn(), + } + mockSliderEl = { + noUiSlider: null, + dataset: {}, + } + mockNoUiSliderCreate = jest.fn(() => { + mockSliderEl.noUiSlider = mockSliderInstance + }) + // Slider path touches formatFileSize on totalSizeLabel + global.window.DOMUtils = { + ...global.window.DOMUtils, + formatFileSize: jest.fn((n) => `${n} B`), + } + }) + + test("should return early when modal root element is missing", () => { + document.getElementById = jest.fn(() => null) + global.noUiSlider = { create: mockNoUiSliderCreate } + + downloadManager.initializeCaptureDownloadSlider( + MODAL_ID, + 10000, + 1000, + {}, + ) + expect(mockNoUiSliderCreate).not.toHaveBeenCalled() + }) + + test("should return early when temporalFilterSlider element is missing", () => { + const modal = mockWebDownloadModal({ temporalFilterSlider: null }) + document.getElementById = jest.fn((id) => + id === MODAL_ID ? modal : null, + ) + global.noUiSlider = { create: mockNoUiSliderCreate } + + downloadManager.initializeCaptureDownloadSlider( + MODAL_ID, + 10000, + 1000, + {}, + ) + expect(mockNoUiSliderCreate).not.toHaveBeenCalled() + }) + + test("should return early when noUiSlider is undefined", () => { + const originalNoUiSlider = global.noUiSlider + global.noUiSlider = undefined + const modal = mockWebDownloadModal({ + temporalFilterSlider: mockSliderEl, + }) + document.getElementById = jest.fn((id) => + id === MODAL_ID ? modal : null, + ) + + expect(() => { + downloadManager.initializeCaptureDownloadSlider( + MODAL_ID, + 10000, + 1000, + {}, + ) + }).not.toThrow() + + global.noUiSlider = originalNoUiSlider + }) + + test("should create slider and set modal dataset and range hint when slider and noUiSlider exist", () => { + const rangeHintEl = { textContent: "" } + const webDownloadModal = mockWebDownloadModal({ + temporalFilterSlider: mockSliderEl, + temporalRangeHint: rangeHintEl, + }) + document.getElementById = jest.fn((id) => + id === MODAL_ID ? webDownloadModal : null, + ) + global.noUiSlider = { create: mockNoUiSliderCreate } + + downloadManager.initializeCaptureDownloadSlider( + MODAL_ID, + 5000, + 500, + { + dataFilesCount: 10, + totalFilesCount: 12, + totalSize: 1000000, + }, + ) + + expect(mockNoUiSliderCreate).toHaveBeenCalledWith( + mockSliderEl, + expect.objectContaining({ + start: [0, 5000], + connect: true, + step: 500, + range: { min: 0, max: 5000 }, + }), + ) + expect(webDownloadModal.dataset.durationMs).toBe("5000") + expect(webDownloadModal.dataset.fileCadenceMs).toBe("500") + expect(rangeHintEl.textContent).toBe("0 – 5000 ms") + }) + + test("should not create slider when durationMs is 0", () => { + const rangeHintEl = { textContent: "" } + const modal = mockWebDownloadModal({ + temporalFilterSlider: mockSliderEl, + temporalRangeHint: rangeHintEl, + }) + document.getElementById = jest.fn((id) => + id === MODAL_ID ? modal : null, + ) + global.noUiSlider = { create: mockNoUiSliderCreate } + + downloadManager.initializeCaptureDownloadSlider( + MODAL_ID, + 0, + 1000, + {}, + ) + + expect(mockNoUiSliderCreate).not.toHaveBeenCalled() + }) + }) + + describe("Web Download Modal", () => { + beforeEach(() => { + downloadManager = new DownloadActionManager({ + permissions: mockPermissions, + }) + }) + + test("openSDKDownloadModal calls openModal when modal element exists", () => { + const modalId = "sdkDownloadModal-ds-1" + const modal = { id: modalId, addEventListener: jest.fn() } + document.getElementById = jest.fn((id) => + id === modalId ? modal : null, + ) + const openSpy = jest + .spyOn(ModalManager.prototype, "openModal") + .mockImplementation(() => {}) + + downloadManager.openSDKDownloadModal("ds-1") + + expect(openSpy).toHaveBeenCalledWith(modalId) + expect(modal.addEventListener).toHaveBeenCalledWith( + "shown.bs.modal", + expect.any(Function), + { once: true }, + ) + openSpy.mockRestore() + }) + + test("should use ModalManager.openModal for opening modals", async () => { + const openSpy = jest + .spyOn(ModalManager.prototype, "openModal") + .mockImplementation(() => {}) + const modalId = "webDownloadModal-test-uuid" + const confirmBtn = { + dataset: {}, + cloneNode: jest.fn(() => confirmBtn), + parentNode: { replaceChild: jest.fn() }, + onclick: null, + innerHTML: "", + disabled: false, + } + document.getElementById = jest.fn((id) => { + if (id === modalId) { + return { id: modalId, addEventListener: jest.fn() } + } + if (id === "webDownloadDatasetName-test-uuid") { + return { textContent: "" } + } + if (id === "confirmWebDownloadBtn-test-uuid") { + return confirmBtn + } + return null + }) + + const btn = { + innerHTML: "Download", + disabled: false, + dataset: {}, + getAttribute: jest.fn((attr) => { + if (attr === "data-item-uuid") return "test-uuid" + if (attr === "data-item-type") return "dataset" + return null + }), + } + + await downloadManager.initializeWebDownloadModal( + "test-uuid", + "dataset", + btn, + ) + + expect(openSpy).toHaveBeenCalledWith( + modalId, + expect.objectContaining({ + trigger: btn, + downloadActionManager: downloadManager, + }), + ) + openSpy.mockRestore() + }) + }) + + describe("Permission Handling", () => { + test("should check dataset download permissions", () => { + downloadManager = new DownloadActionManager({ + permissions: mockPermissions, + }) + + expect(downloadManager.permissions.canDownload()).toBe(true) + }) + + test("should check capture download permissions", () => { + const denyPermissions = { + canDownload: jest.fn(() => false), + } + downloadManager = new DownloadActionManager({ + permissions: denyPermissions, + }) + + expect(downloadManager.permissions.canDownload()).toBe(false) + }) + + test("should default to true when no permissions specified", () => { + downloadManager = new DownloadActionManager({ + permissions: mockPermissions, + }) + + expect(downloadManager.permissions.canDownload()).toBe(true) + }) + }) + + describe("Error Handling", () => { + beforeEach(() => { + downloadManager = new DownloadActionManager({ + permissions: mockPermissions, + }) + }) + + test("should handle missing button elements", () => { + document.querySelectorAll.mockReturnValue([]) + + expect(() => { + downloadManager.initializeWebDownloadButtons() + }).not.toThrow() + }) + + test("should handle missing modal elements", () => { + expect(() => { + downloadManager.closeModal("test-modal") + }).not.toThrow() + }) + }) + + describe("Cleanup", () => { + beforeEach(() => { + downloadManager = new DownloadActionManager({ + permissions: mockPermissions, + }) + }) + + test("cleanup removes click listeners from web download buttons", () => { + const button = { + removeEventListener: jest.fn(), + } + document.querySelectorAll = jest.fn(() => [button]) + + downloadManager.cleanup() + + expect(button.removeEventListener).toHaveBeenCalledWith( + "click", + downloadManager.initializeWebDownloadButtons, + ) + }) + }) +}) diff --git a/gateway/sds_gateway/static/js/actions/__tests__/DownloadInstructionsManager.test.js b/gateway/sds_gateway/static/js/actions/__tests__/DownloadInstructionsManager.test.js index 84426aa70..151870002 100644 --- a/gateway/sds_gateway/static/js/actions/__tests__/DownloadInstructionsManager.test.js +++ b/gateway/sds_gateway/static/js/actions/__tests__/DownloadInstructionsManager.test.js @@ -2,65 +2,73 @@ * Jest tests for DownloadInstructionsManager */ -import { DownloadInstructionsManager } from "../DownloadInstructionsManager.js"; +import { DownloadInstructionsManager } from "../DownloadInstructionsManager.js" describe("DownloadInstructionsManager", () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.useFakeTimers(); - global.window.UserInputController = { - execCommandCopyFallback: jest.fn(), - }; - }); + beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers() + global.window.UserInputController = { + execCommandCopyFallback: jest.fn(), + } + }) - afterEach(() => { - jest.useRealTimers(); - }); + afterEach(() => { + jest.useRealTimers() + }) - test("copy button uses clipboard API and shows success state", async () => { - document.body.innerHTML = ` + test("copy button uses clipboard API and shows success state", async () => { + document.body.innerHTML = `
curl example
- `; - const btn = document.querySelector(".copy-btn"); - Object.assign(navigator, { - clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, - }); - Object.defineProperty(window, "isSecureContext", { value: true, configurable: true }); + ` + const btn = document.querySelector(".copy-btn") + Object.assign(navigator, { + clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, + }) + Object.defineProperty(window, "isSecureContext", { + value: true, + configurable: true, + }) - new DownloadInstructionsManager(); - btn.click(); + new DownloadInstructionsManager() + btn.click() - await Promise.resolve(); + await Promise.resolve() - expect(navigator.clipboard.writeText).toHaveBeenCalledWith("curl example"); - expect(btn.classList.contains("copied")).toBe(true); - expect(btn.innerHTML).toContain("Copied"); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + "curl example", + ) + expect(btn.classList.contains("copied")).toBe(true) + expect(btn.innerHTML).toContain("Copied") - jest.advanceTimersByTime(2000); - expect(btn.classList.contains("copied")).toBe(false); - }); + jest.advanceTimersByTime(2000) + expect(btn.classList.contains("copied")).toBe(false) + }) - test("falls back when clipboard write fails", async () => { - document.body.innerHTML = ` + test("falls back when clipboard write fails", async () => { + document.body.innerHTML = `
text
- `; - const btn = document.querySelector(".copy-btn"); - Object.assign(navigator, { - clipboard: { - writeText: jest.fn().mockRejectedValue(new Error("denied")), - }, - }); - Object.defineProperty(window, "isSecureContext", { value: true, configurable: true }); + ` + const btn = document.querySelector(".copy-btn") + Object.assign(navigator, { + clipboard: { + writeText: jest.fn().mockRejectedValue(new Error("denied")), + }, + }) + Object.defineProperty(window, "isSecureContext", { + value: true, + configurable: true, + }) - new DownloadInstructionsManager(); - btn.click(); - await Promise.resolve(); - await Promise.resolve(); + new DownloadInstructionsManager() + btn.click() + await Promise.resolve() + await Promise.resolve() - expect(window.UserInputController.execCommandCopyFallback).toHaveBeenCalledWith( - "text", - ); - }); -}); + expect( + window.UserInputController.execCommandCopyFallback, + ).toHaveBeenCalledWith("text") + }) +}) diff --git a/gateway/sds_gateway/static/js/actions/__tests__/PublishActionManager.test.js b/gateway/sds_gateway/static/js/actions/__tests__/PublishActionManager.test.js index 31fae0a39..bc18b5a80 100644 --- a/gateway/sds_gateway/static/js/actions/__tests__/PublishActionManager.test.js +++ b/gateway/sds_gateway/static/js/actions/__tests__/PublishActionManager.test.js @@ -4,175 +4,177 @@ */ // Import the PublishActionManager class -import { PublishActionManager } from "../PublishActionManager.js"; +import { PublishActionManager } from "../PublishActionManager.js" describe("PublishActionManager", () => { - let publishManager; - let mockAPIClient; - let mockStatusBadge; - let mockPublishToggle; - let mockPublicOption; - let mockPrivateOption; - - beforeEach(() => { - mockAPIClient = { - post: jest.fn(), - }; - window.APIClient = mockAPIClient; - - mockStatusBadge = { - textContent: "Draft", - className: "badge bg-secondary", - }; - - mockPublishToggle = { checked: true }; - mockPublicOption = { checked: true }; - mockPrivateOption = { checked: false }; - - document.getElementById = jest.fn((id) => { - if (id === "publishDatasetBtn-test-uuid") { - return { - disabled: false, - innerHTML: "Publish", - }; - } - if (id === "publish-dataset-modal-test-uuid") { - return { id: "publish-dataset-modal-test-uuid" }; - } - return null; - }); - - global.bootstrap = { - Modal: { - getInstance: jest.fn(() => ({ - hide: jest.fn(), - })), - }, - }; - - window.DOMUtils = { - showMessage: jest.fn(), - }; - - window.location = { reload: jest.fn() }; - - publishManager = new PublishActionManager(); - }); - - test("should publish dataset as final and public when toggle is checked", async () => { - mockAPIClient.post.mockResolvedValue({ - success: true, - message: "Dataset published successfully", - }); - - await publishManager.handlePublish( - "test-uuid", - mockStatusBadge, - mockPublishToggle, - mockPrivateOption, - mockPublicOption, - ); - - expect(mockAPIClient.post).toHaveBeenCalledWith( - "/users/publish-dataset/test-uuid/", - { - status: "final", - is_public: "true", - }, - ); - expect(window.DOMUtils.showMessage).toHaveBeenCalledWith( - "Dataset published successfully", - expect.objectContaining({ - variant: "success", - placement: "toast", - presentation: "toast", - }), - ); - }); - - test("should publish dataset as draft and private when toggle is unchecked", async () => { - mockPublishToggle.checked = false; - mockPublicOption.checked = false; - mockPrivateOption.checked = true; - - mockAPIClient.post.mockResolvedValue({ - success: true, - }); - - await publishManager.handlePublish( - "test-uuid", - mockStatusBadge, - mockPublishToggle, - mockPrivateOption, - mockPublicOption, - ); - - expect(mockAPIClient.post).toHaveBeenCalledWith( - "/users/publish-dataset/test-uuid/", - { - status: "draft", - is_public: "false", - }, - ); - }); - - test("should handle API errors gracefully", async () => { - mockAPIClient.post.mockResolvedValue({ - success: false, - error: "Validation failed", - }); - - const publishBtn = document.getElementById("publishDatasetBtn-test-uuid"); - - await publishManager.handlePublish( - "test-uuid", - mockStatusBadge, - mockPublishToggle, - mockPrivateOption, - mockPublicOption, - ); - - expect(window.DOMUtils.showMessage).toHaveBeenCalledWith( - "Validation failed", - expect.objectContaining({ - variant: "danger", - placement: "toast", - presentation: "toast", - }), - ); - expect(publishBtn.disabled).toBe(false); - expect(publishBtn.innerHTML).toBe("Publish"); - }); - - test("should reload page after successful publish", async () => { - jest.useFakeTimers(); - const reloadMock = jest.fn(); - Object.defineProperty(window, "location", { - value: { reload: reloadMock }, - writable: true, - configurable: true, - }); - mockAPIClient.post.mockResolvedValue({ - success: true, - message: "Published", - }); - publishManager.closeModal = jest.fn(); - - await publishManager.handlePublish( - "test-uuid", - mockStatusBadge, - mockPublishToggle, - mockPrivateOption, - mockPublicOption, - ); - - expect(publishManager.closeModal).toHaveBeenCalledWith( - "publish-dataset-modal-test-uuid", - ); - expect(reloadMock).not.toHaveBeenCalled(); - - jest.advanceTimersByTime(1000); - expect(reloadMock).toHaveBeenCalledTimes(1); - jest.useRealTimers(); - }); -}); + let publishManager + let mockAPIClient + let mockStatusBadge + let mockPublishToggle + let mockPublicOption + let mockPrivateOption + + beforeEach(() => { + mockAPIClient = { + post: jest.fn(), + } + window.APIClient = mockAPIClient + + mockStatusBadge = { + textContent: "Draft", + className: "badge bg-secondary", + } + + mockPublishToggle = { checked: true } + mockPublicOption = { checked: true } + mockPrivateOption = { checked: false } + + document.getElementById = jest.fn((id) => { + if (id === "publishDatasetBtn-test-uuid") { + return { + disabled: false, + innerHTML: "Publish", + } + } + if (id === "publish-dataset-modal-test-uuid") { + return { id: "publish-dataset-modal-test-uuid" } + } + return null + }) + + global.bootstrap = { + Modal: { + getInstance: jest.fn(() => ({ + hide: jest.fn(), + })), + }, + } + + window.DOMUtils = { + showMessage: jest.fn(), + } + + window.location = { reload: jest.fn() } + + publishManager = new PublishActionManager() + }) + + test("should publish dataset as final and public when toggle is checked", async () => { + mockAPIClient.post.mockResolvedValue({ + success: true, + message: "Dataset published successfully", + }) + + await publishManager.handlePublish( + "test-uuid", + mockStatusBadge, + mockPublishToggle, + mockPrivateOption, + mockPublicOption, + ) + + expect(mockAPIClient.post).toHaveBeenCalledWith( + "/users/publish-dataset/test-uuid/", + { + status: "final", + is_public: "true", + }, + ) + expect(window.DOMUtils.showMessage).toHaveBeenCalledWith( + "Dataset published successfully", + expect.objectContaining({ + variant: "success", + placement: "toast", + presentation: "toast", + }), + ) + }) + + test("should publish dataset as draft and private when toggle is unchecked", async () => { + mockPublishToggle.checked = false + mockPublicOption.checked = false + mockPrivateOption.checked = true + + mockAPIClient.post.mockResolvedValue({ + success: true, + }) + + await publishManager.handlePublish( + "test-uuid", + mockStatusBadge, + mockPublishToggle, + mockPrivateOption, + mockPublicOption, + ) + + expect(mockAPIClient.post).toHaveBeenCalledWith( + "/users/publish-dataset/test-uuid/", + { + status: "draft", + is_public: "false", + }, + ) + }) + + test("should handle API errors gracefully", async () => { + mockAPIClient.post.mockResolvedValue({ + success: false, + error: "Validation failed", + }) + + const publishBtn = document.getElementById( + "publishDatasetBtn-test-uuid", + ) + + await publishManager.handlePublish( + "test-uuid", + mockStatusBadge, + mockPublishToggle, + mockPrivateOption, + mockPublicOption, + ) + + expect(window.DOMUtils.showMessage).toHaveBeenCalledWith( + "Validation failed", + expect.objectContaining({ + variant: "danger", + placement: "toast", + presentation: "toast", + }), + ) + expect(publishBtn.disabled).toBe(false) + expect(publishBtn.innerHTML).toBe("Publish") + }) + + test("should reload page after successful publish", async () => { + jest.useFakeTimers() + const reloadMock = jest.fn() + Object.defineProperty(window, "location", { + value: { reload: reloadMock }, + writable: true, + configurable: true, + }) + mockAPIClient.post.mockResolvedValue({ + success: true, + message: "Published", + }) + publishManager.closeModal = jest.fn() + + await publishManager.handlePublish( + "test-uuid", + mockStatusBadge, + mockPublishToggle, + mockPrivateOption, + mockPublicOption, + ) + + expect(publishManager.closeModal).toHaveBeenCalledWith( + "publish-dataset-modal-test-uuid", + ) + expect(reloadMock).not.toHaveBeenCalled() + + jest.advanceTimersByTime(1000) + expect(reloadMock).toHaveBeenCalledTimes(1) + jest.useRealTimers() + }) +}) diff --git a/gateway/sds_gateway/static/js/actions/__tests__/QuickAddToDatasetManager.test.js b/gateway/sds_gateway/static/js/actions/__tests__/QuickAddToDatasetManager.test.js index 3bcaa11f6..88e8f5053 100644 --- a/gateway/sds_gateway/static/js/actions/__tests__/QuickAddToDatasetManager.test.js +++ b/gateway/sds_gateway/static/js/actions/__tests__/QuickAddToDatasetManager.test.js @@ -2,10 +2,10 @@ * Jest tests for QuickAddToDatasetManager */ -import { QuickAddToDatasetManager } from "../QuickAddToDatasetManager.js"; +import { QuickAddToDatasetManager } from "../QuickAddToDatasetManager.js" function installQuickAddModalDom() { - document.body.innerHTML = ` + document.body.innerHTML = `
@@ -14,83 +14,83 @@ function installQuickAddModalDom() {
- `; + ` } describe("QuickAddToDatasetManager", () => { - beforeEach(() => { - jest.clearAllMocks(); - installQuickAddModalDom(); - window.DOMUtils = { - show: jest.fn(), - showMessage: jest.fn().mockResolvedValue(true), - }; - window.APIClient = { - get: jest.fn(), - post: jest.fn(), - }; - window.QuickAddApi = { - formatQuickAddSummary: jest.fn(() => "2 added."), - postQuickAddCapture: jest.fn(), - postQuickAddCaptures: jest.fn(), - }; - }); + beforeEach(() => { + jest.clearAllMocks() + installQuickAddModalDom() + window.DOMUtils = { + show: jest.fn(), + showMessage: jest.fn().mockResolvedValue(true), + } + window.APIClient = { + get: jest.fn(), + post: jest.fn(), + } + window.QuickAddApi = { + formatQuickAddSummary: jest.fn(() => "2 added."), + postQuickAddCapture: jest.fn(), + postQuickAddCaptures: jest.fn(), + } + }) - test("loadDatasets populates select from API", async () => { - window.APIClient.get.mockResolvedValue({ - datasets: [ - { uuid: "ds-1", name: "Dataset One" }, - { uuid: "ds-2", name: "Dataset Two" }, - ], - }); + test("loadDatasets populates select from API", async () => { + window.APIClient.get.mockResolvedValue({ + datasets: [ + { uuid: "ds-1", name: "Dataset One" }, + { uuid: "ds-2", name: "Dataset Two" }, + ], + }) - const mgr = new QuickAddToDatasetManager(); - await mgr.loadDatasets(); + const mgr = new QuickAddToDatasetManager() + await mgr.loadDatasets() - const select = document.getElementById("quick-add-dataset-select"); - expect(select.options.length).toBe(3); - expect(select.options[1].value).toBe("ds-1"); - expect(mgr.confirmBtn.disabled).toBe(true); - }); + const select = document.getElementById("quick-add-dataset-select") + expect(select.options.length).toBe(3) + expect(select.options[1].value).toBe("ds-1") + expect(mgr.confirmBtn.disabled).toBe(true) + }) - test("handleSingleAdd closes with toast on success", async () => { - window.QuickAddApi.postQuickAddCapture.mockResolvedValue({ - success: true, - added: 1, - skipped: 0, - errors: [], - }); + test("handleSingleAdd closes with toast on success", async () => { + window.QuickAddApi.postQuickAddCapture.mockResolvedValue({ + success: true, + added: 1, + skipped: 0, + errors: [], + }) - const mgr = new QuickAddToDatasetManager(); - mgr.currentCaptureUuid = "cap-1"; - mgr.selectEl.value = "ds-1"; - mgr.showToast = jest.fn(); - mgr.closeModal = jest.fn(); - mgr.modalEl = document.getElementById("quickAddToDatasetModal"); + const mgr = new QuickAddToDatasetManager() + mgr.currentCaptureUuid = "cap-1" + mgr.selectEl.value = "ds-1" + mgr.showToast = jest.fn() + mgr.closeModal = jest.fn() + mgr.modalEl = document.getElementById("quickAddToDatasetModal") - await mgr.handleSingleAdd("ds-1"); + await mgr.handleSingleAdd("ds-1") - expect(window.QuickAddApi.postQuickAddCapture).toHaveBeenCalledWith( - "/users/quick-add/", - "ds-1", - "cap-1", - ); - expect(mgr.closeModal).toHaveBeenCalled(); - }); + expect(window.QuickAddApi.postQuickAddCapture).toHaveBeenCalledWith( + "/users/quick-add/", + "ds-1", + "cap-1", + ) + expect(mgr.closeModal).toHaveBeenCalled() + }) - test("handleAdd shows warning when no capture selected", async () => { - const mgr = new QuickAddToDatasetManager(); - const opt = document.createElement("option"); - opt.value = "ds-1"; - mgr.selectEl.appendChild(opt); - mgr.selectEl.value = "ds-1"; - mgr.showInlineMessage = jest.fn(); + test("handleAdd shows warning when no capture selected", async () => { + const mgr = new QuickAddToDatasetManager() + const opt = document.createElement("option") + opt.value = "ds-1" + mgr.selectEl.appendChild(opt) + mgr.selectEl.value = "ds-1" + mgr.showInlineMessage = jest.fn() - await mgr.handleAdd(); + await mgr.handleAdd() - expect(mgr.showInlineMessage).toHaveBeenCalledWith( - expect.stringContaining("Select at least one capture"), - "warning", - ); - }); -}); + expect(mgr.showInlineMessage).toHaveBeenCalledWith( + expect.stringContaining("Select at least one capture"), + "warning", + ) + }) +}) diff --git a/gateway/sds_gateway/static/js/actions/__tests__/ShareActionManager.test.js b/gateway/sds_gateway/static/js/actions/__tests__/ShareActionManager.test.js index bbdab497c..172fc6194 100644 --- a/gateway/sds_gateway/static/js/actions/__tests__/ShareActionManager.test.js +++ b/gateway/sds_gateway/static/js/actions/__tests__/ShareActionManager.test.js @@ -4,508 +4,521 @@ */ // Import the ShareActionManager class -import { ShareActionManager } from "../ShareActionManager.js"; +import { ShareActionManager } from "../ShareActionManager.js" const { - createDefaultShareActionConfig, - setupShareActionStandardTest, - createShareSearchTestContext, -} = require("../../__tests__/helpers/actionTestMocks.js"); + createDefaultShareActionConfig, + setupShareActionStandardTest, + createShareSearchTestContext, +} = require("../../__tests__/helpers/actionTestMocks.js") describe("ShareActionManager", () => { - let shareManager; - let mockConfig; - - beforeEach(() => { - mockConfig = createDefaultShareActionConfig(); - setupShareActionStandardTest(); - }); - - describe("Initialization", () => { - test("should initialize with correct configuration", () => { - shareManager = new ShareActionManager(mockConfig); - - expect(shareManager.itemUuid).toBe("test-uuid"); - expect(shareManager.itemType).toBe("dataset"); - expect(shareManager.permissions.canShare).toBe(true); - }); - - test("should initialize with empty state", () => { - shareManager = new ShareActionManager(mockConfig); - - expect(shareManager.selectedUsersMap).toEqual({}); - expect(shareManager.pendingRemovals).toBeInstanceOf(Set); - expect(shareManager.pendingPermissionChanges).toBeInstanceOf(Map); - }); - }); - - describe("Searching Users", () => { - let mockAPIClient; - let mockDropdown; - - beforeEach(() => { - ({ mockAPIClient, mockDropdown, shareManager } = - createShareSearchTestContext(ShareActionManager)); - }); - - test("should cancel previous request when new search starts", async () => { - const abortController1 = new AbortController(); - shareManager.currentRequest = abortController1; - const abortSpy = jest.spyOn(abortController1, "abort"); - - mockAPIClient.get.mockResolvedValue([]); - mockAPIClient.post.mockResolvedValue({ html: "
Results
" }); - - await shareManager.searchUsers("new query", mockDropdown); - - expect(abortSpy).toHaveBeenCalled(); - expect(mockAPIClient.get).toHaveBeenCalledWith( - "/users/share-item/dataset/test-uuid/", - { q: "new query", limit: 10 }, - null, - ); - }); - - test("should handle search errors gracefully", async () => { - mockAPIClient.get.mockRejectedValue(new Error("Network error")); - - await shareManager.searchUsers("test", mockDropdown); - - expect(shareManager.displayError).toHaveBeenCalledWith(mockDropdown); - expect(shareManager.currentRequest).toBeNull(); - }); - - test("should ignore AbortError when request is cancelled", async () => { - const abortError = new Error("Request aborted"); - abortError.name = "AbortError"; - mockAPIClient.get.mockRejectedValue(abortError); - - await shareManager.searchUsers("test", mockDropdown); - - expect(shareManager.displayError).not.toHaveBeenCalled(); - expect(shareManager.currentRequest).toBeNull(); - }); - - test("should render HTML results from server", async () => { - mockAPIClient.get.mockResolvedValue([ - { email: "user1@example.com", name: "User 1" }, - ]); - mockAPIClient.post.mockResolvedValue({ - html: "
Rendered HTML
", - }); - - await shareManager.searchUsers("user", mockDropdown); - - expect(mockAPIClient.post).toHaveBeenCalledWith( - "/users/render-html/", - expect.objectContaining({ - template: "users/components/user_search_results.html", - context: { - users: [{ email: "user1@example.com", name: "User 1" }], - }, - }), - null, - true, - ); - expect(shareManager.displayResults).toHaveBeenCalledWith( - { html: "
Rendered HTML
" }, - mockDropdown, - ); - }); - - test("should handle empty search results", async () => { - mockAPIClient.get.mockResolvedValue(null); - shareManager.displayResults = jest.fn(); - - await shareManager.searchUsers("nonexistent", mockDropdown); - - expect(shareManager.displayResults).toHaveBeenCalledWith( - { html: null, results: [] }, - mockDropdown, - ); - }); - }); - - describe("Selecting Users", () => { - let shareManager; - let mockInput; - let mockItem; - - beforeEach(() => { - window.PermissionLevels = { - VIEWER: "viewer", - }; - - mockInput = { - id: "user-search-test-uuid", - value: "test@example.com", - focus: jest.fn(), - }; - - mockItem = { - dataset: { - userName: "Test User", - userEmail: "test@example.com", - userType: "user", - }, - closest: jest.fn(() => ({ - querySelector: jest.fn(), - })), - }; - - shareManager = new ShareActionManager({ - itemUuid: "test-uuid", - itemType: "dataset", - permissions: {}, - }); - shareManager.renderChips = jest.fn(); - shareManager.hideDropdown = jest.fn(); - shareManager.checkUserInGroup = jest.fn(); - }); - - test("should add user to selectedUsersMap when not already selected", () => { - shareManager.selectUser(mockItem, mockInput); - - expect(shareManager.selectedUsersMap[mockInput.id]).toHaveLength(1); - expect(shareManager.selectedUsersMap[mockInput.id][0]).toEqual({ - name: "Test User", - email: "test@example.com", - type: "user", - permission_level: "viewer", - }); - expect(shareManager.renderChips).toHaveBeenCalledWith(mockInput); - expect(mockInput.value).toBe(""); - }); - - test("should not add duplicate users", () => { - shareManager.selectedUsersMap[mockInput.id] = [ - { email: "test@example.com", name: "Test User" }, - ]; - - shareManager.selectUser(mockItem, mockInput); - - expect(shareManager.selectedUsersMap[mockInput.id]).toHaveLength(1); - expect(shareManager.renderChips).not.toHaveBeenCalled(); - }); - - test("should check if user is in selected group before adding", () => { - shareManager.selectedUsersMap[mockInput.id] = [ - { - email: "group:group-uuid", - name: "Test Group", - type: "group", - }, - ]; - - shareManager.selectUser(mockItem, mockInput); - - expect(shareManager.checkUserInGroup).toHaveBeenCalledWith( - "test@example.com", - { email: "group:group-uuid", name: "Test Group", type: "group" }, - mockInput, - "Test User", - ); - expect(shareManager.selectedUsersMap[mockInput.id]).toHaveLength(1); - }); - - test("should handle group selection with member count", () => { - mockItem.dataset.userType = "group"; - mockItem.dataset.memberCount = "5"; - - shareManager.selectUser(mockItem, mockInput); - - expect(shareManager.selectedUsersMap[mockInput.id][0]).toEqual({ - name: "Test User", - email: "test@example.com", - type: "group", - permission_level: "viewer", - member_count: 5, - }); - }); - }); - - describe("Share Methods", () => { - let shareManager; - let mockAPIClient; - - beforeEach(() => { - mockAPIClient = { - post: jest.fn(), - }; - window.APIClient = mockAPIClient; - - window.PermissionLevels = { - VIEWER: "viewer", - CONTRIBUTOR: "contributor", - CO_OWNER: "co-owner", - }; - - document.getElementById = jest.fn((id) => { - if (id === "notify-users-checkbox-test-uuid") { - return { checked: false, value: "" }; - } - return null; - }); - - window.DOMUtils = { - showMessage: jest.fn(), - }; - - window.location = { reload: jest.fn() }; - - shareManager = new ShareActionManager({ - itemUuid: "test-uuid", - itemType: "dataset", - permissions: {}, - }); - - shareManager.selectedUsersMap = { - "user-search-test-uuid": [ - { email: "user1@example.com", permission_level: "viewer" }, - { email: "user2@example.com", permission_level: "contributor" }, - ], - }; - shareManager.pendingRemovals = new Set(["user3@example.com"]); - shareManager.pendingPermissionChanges = new Map([ - ["user1@example.com", "contributor"], - ]); - shareManager.closeModal = jest.fn(); - }); - - test("should share item with multiple users and handle removals", async () => { - mockAPIClient.post.mockResolvedValue({ - success: true, - message: "Dataset shared successfully", - }); - - await shareManager.handleShareItem(); - - expect(mockAPIClient.post).toHaveBeenCalledWith( - "/users/share-item/dataset/test-uuid/", - expect.objectContaining({ - "user-search": "user1@example.com,user2@example.com", - remove_users: JSON.stringify(["user3@example.com"]), - permission_changes: JSON.stringify([ - ["user1@example.com", "contributor"], - ]), - }), - ); - expect(window.DOMUtils.showMessage).toHaveBeenCalledWith( - "Dataset shared successfully", - expect.objectContaining({ - variant: "success", - placement: "toast", - presentation: "toast", - }), - ); - }); - - test("should include notification when checkbox is checked", async () => { - document.getElementById.mockReturnValue({ - checked: true, - value: "", - }); - - mockAPIClient.post.mockResolvedValue({ success: true }); - - await shareManager.handleShareItem(); - - expect(mockAPIClient.post).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - notify_users: "1", - }), - ); - }); - }); - - describe("List refresh after share", () => { - let shareManager; - let mockAPIClient; - let reloadMock; - - beforeEach(() => { - mockAPIClient = { post: jest.fn() }; - window.APIClient = mockAPIClient; - window.PermissionLevels = { VIEWER: "viewer" }; - document.getElementById = jest.fn((id) => { - if (id === "notify-users-checkbox-test-uuid") { - return { checked: false, value: "" }; - } - return null; - }); - window.DOMUtils = { showMessage: jest.fn() }; - reloadMock = jest.fn(); - Object.defineProperty(window, "location", { - value: { reload: reloadMock }, - writable: true, - configurable: true, - }); - window.listRefreshManager = { - loadTable: jest.fn().mockResolvedValue("
"), - }; - - shareManager = new ShareActionManager({ - itemUuid: "test-uuid", - itemType: "dataset", - permissions: {}, - }); - shareManager.selectedUsersMap = { - "user-search-test-uuid": [ - { email: "user1@example.com", permission_level: "viewer" }, - ], - }; - shareManager.pendingRemovals = new Set(); - shareManager.pendingPermissionChanges = new Map(); - shareManager.closeModal = jest.fn(); - shareManager.showToast = jest.fn(); - }); - - test("should await listRefreshManager.loadTable after successful share", async () => { - mockAPIClient.post.mockResolvedValue({ success: true }); - - await shareManager.handleShareItem(); - - expect(shareManager.closeModal).toHaveBeenCalledWith( - "shareModal-test-uuid", - ); - expect(window.listRefreshManager.loadTable).toHaveBeenCalled(); - expect(reloadMock).not.toHaveBeenCalled(); - }); - - test("should reload page when listRefreshManager is unavailable", async () => { - window.listRefreshManager = undefined; - console.warn = jest.fn(); - mockAPIClient.post.mockResolvedValue({ success: true }); - - await shareManager.handleShareItem(); - - expect(console.warn).toHaveBeenCalledWith( - "listRefreshManager not available, reloading page", - ); - expect(reloadMock).toHaveBeenCalled(); - }); - }); - - describe("Utility methods", () => { - beforeEach(() => { - window.DOMUtils.showMessage = jest.fn(); - shareManager = new ShareActionManager(mockConfig); - }); - - test("getPermissionButtonText includes icon class and capitalized label", () => { - shareManager.permissions = { - getPermissionIcon: jest.fn(() => "bi-eye"), - }; - - const text = shareManager.getPermissionButtonText("viewer"); - - expect(text).toContain('class="bi bi-eye me-1"'); - expect(text).toContain("Viewer"); - expect(shareManager.permissions.getPermissionIcon).toHaveBeenCalledWith( - "viewer", - ); - }); - - test("showToast routes success and danger through showMessage", () => { - shareManager.showToast("Saved", "success"); - shareManager.showToast("Failed", "danger"); - - expect(shareManager.showMessage).toHaveBeenCalledWith( - "Saved", - expect.objectContaining({ variant: "success", presentation: "toast" }), - ); - expect(shareManager.showMessage).toHaveBeenCalledWith( - "Failed", - expect.objectContaining({ variant: "danger", presentation: "toast" }), - ); - }); - }); - - describe("Error Handling", () => { - beforeEach(() => { - shareManager = new ShareActionManager(mockConfig); - }); - - test("should handle missing DOM elements gracefully", () => { - document.getElementById = jest.fn(() => null); - document.querySelector = jest.fn(() => null); - - // Mock bootstrap.Modal.getInstance to return null for missing modals - global.bootstrap.Modal.getInstance = jest.fn(() => null); - - expect(() => { - shareManager.closeModal(); - shareManager.updateSaveButtonState("test-uuid"); - }).not.toThrow(); - }); - - test("searchUsers surfaces non-abort errors via displayError", async () => { - const mockDropdown = { innerHTML: "" }; - shareManager.displayError = jest.fn(); - global.APIClient.get.mockRejectedValue(new Error("API Error")); - - await shareManager.searchUsers("x", mockDropdown); - - expect(shareManager.displayError).toHaveBeenCalledWith(mockDropdown); - expect(shareManager.currentRequest).toBeNull(); - }); - - test("should handle invalid data gracefully", () => { - expect(() => { - shareManager.handlePermissionLevelChange( - "test@example.com", - "read", - "test-uuid", - ); - }).not.toThrow(); - }); - }); - - describe("State management", () => { - const itemUuid = "test-uuid"; - - beforeEach(() => { - shareManager = new ShareActionManager(mockConfig); - }); - - test("clearSelections resets maps and pending change sets", () => { - shareManager.selectedUsersMap = { - [`user-search-${itemUuid}`]: [{ email: "a@b.com" }], - }; - shareManager.pendingRemovals.add("a@b.com"); - shareManager.pendingPermissionChanges.set("a@b.com", "viewer"); - - shareManager.clearSelections(); - - expect(shareManager.selectedUsersMap).toEqual({}); - expect(shareManager.pendingRemovals.size).toBe(0); - expect(shareManager.pendingPermissionChanges.size).toBe(0); - }); - - test("updateSaveButtonState disables save when no pending work", () => { - const saveBtn = { disabled: false }; - document.getElementById = jest.fn((id) => - id === `share-item-btn-${itemUuid}` ? saveBtn : null, - ); - - shareManager.updateSaveButtonState(itemUuid); - - expect(saveBtn.disabled).toBe(true); - }); - - test("updateSaveButtonState enables save when users are selected", () => { - const saveBtn = { disabled: true }; - document.getElementById = jest.fn((id) => - id === `share-item-btn-${itemUuid}` ? saveBtn : null, - ); - shareManager.selectedUsersMap[`user-search-${itemUuid}`] = [ - { email: "user@example.com" }, - ]; - - shareManager.updateSaveButtonState(itemUuid); - - expect(saveBtn.disabled).toBe(false); - }); - }); -}); + let shareManager + let mockConfig + + beforeEach(() => { + mockConfig = createDefaultShareActionConfig() + setupShareActionStandardTest() + }) + + describe("Initialization", () => { + test("should initialize with correct configuration", () => { + shareManager = new ShareActionManager(mockConfig) + + expect(shareManager.itemUuid).toBe("test-uuid") + expect(shareManager.itemType).toBe("dataset") + expect(shareManager.permissions.canShare).toBe(true) + }) + + test("should initialize with empty state", () => { + shareManager = new ShareActionManager(mockConfig) + + expect(shareManager.selectedUsersMap).toEqual({}) + expect(shareManager.pendingRemovals).toBeInstanceOf(Set) + expect(shareManager.pendingPermissionChanges).toBeInstanceOf(Map) + }) + }) + + describe("Searching Users", () => { + let mockAPIClient + let mockDropdown + + beforeEach(() => { + ;({ mockAPIClient, mockDropdown, shareManager } = + createShareSearchTestContext(ShareActionManager)) + }) + + test("should cancel previous request when new search starts", async () => { + const abortController1 = new AbortController() + shareManager.currentRequest = abortController1 + const abortSpy = jest.spyOn(abortController1, "abort") + + mockAPIClient.get.mockResolvedValue([]) + mockAPIClient.post.mockResolvedValue({ html: "
Results
" }) + + await shareManager.searchUsers("new query", mockDropdown) + + expect(abortSpy).toHaveBeenCalled() + expect(mockAPIClient.get).toHaveBeenCalledWith( + "/users/share-item/dataset/test-uuid/", + { q: "new query", limit: 10 }, + null, + ) + }) + + test("should handle search errors gracefully", async () => { + mockAPIClient.get.mockRejectedValue(new Error("Network error")) + + await shareManager.searchUsers("test", mockDropdown) + + expect(shareManager.displayError).toHaveBeenCalledWith(mockDropdown) + expect(shareManager.currentRequest).toBeNull() + }) + + test("should ignore AbortError when request is cancelled", async () => { + const abortError = new Error("Request aborted") + abortError.name = "AbortError" + mockAPIClient.get.mockRejectedValue(abortError) + + await shareManager.searchUsers("test", mockDropdown) + + expect(shareManager.displayError).not.toHaveBeenCalled() + expect(shareManager.currentRequest).toBeNull() + }) + + test("should render HTML results from server", async () => { + mockAPIClient.get.mockResolvedValue([ + { email: "user1@example.com", name: "User 1" }, + ]) + mockAPIClient.post.mockResolvedValue({ + html: "
Rendered HTML
", + }) + + await shareManager.searchUsers("user", mockDropdown) + + expect(mockAPIClient.post).toHaveBeenCalledWith( + "/users/render-html/", + expect.objectContaining({ + template: "users/components/user_search_results.html", + context: { + users: [{ email: "user1@example.com", name: "User 1" }], + }, + }), + null, + true, + ) + expect(shareManager.displayResults).toHaveBeenCalledWith( + { html: "
Rendered HTML
" }, + mockDropdown, + ) + }) + + test("should handle empty search results", async () => { + mockAPIClient.get.mockResolvedValue(null) + shareManager.displayResults = jest.fn() + + await shareManager.searchUsers("nonexistent", mockDropdown) + + expect(shareManager.displayResults).toHaveBeenCalledWith( + { html: null, results: [] }, + mockDropdown, + ) + }) + }) + + describe("Selecting Users", () => { + let shareManager + let mockInput + let mockItem + + beforeEach(() => { + window.PermissionLevels = { + VIEWER: "viewer", + } + + mockInput = { + id: "user-search-test-uuid", + value: "test@example.com", + focus: jest.fn(), + } + + mockItem = { + dataset: { + userName: "Test User", + userEmail: "test@example.com", + userType: "user", + }, + closest: jest.fn(() => ({ + querySelector: jest.fn(), + })), + } + + shareManager = new ShareActionManager({ + itemUuid: "test-uuid", + itemType: "dataset", + permissions: {}, + }) + shareManager.renderChips = jest.fn() + shareManager.hideDropdown = jest.fn() + shareManager.checkUserInGroup = jest.fn() + }) + + test("should add user to selectedUsersMap when not already selected", () => { + shareManager.selectUser(mockItem, mockInput) + + expect(shareManager.selectedUsersMap[mockInput.id]).toHaveLength(1) + expect(shareManager.selectedUsersMap[mockInput.id][0]).toEqual({ + name: "Test User", + email: "test@example.com", + type: "user", + permission_level: "viewer", + }) + expect(shareManager.renderChips).toHaveBeenCalledWith(mockInput) + expect(mockInput.value).toBe("") + }) + + test("should not add duplicate users", () => { + shareManager.selectedUsersMap[mockInput.id] = [ + { email: "test@example.com", name: "Test User" }, + ] + + shareManager.selectUser(mockItem, mockInput) + + expect(shareManager.selectedUsersMap[mockInput.id]).toHaveLength(1) + expect(shareManager.renderChips).not.toHaveBeenCalled() + }) + + test("should check if user is in selected group before adding", () => { + shareManager.selectedUsersMap[mockInput.id] = [ + { + email: "group:group-uuid", + name: "Test Group", + type: "group", + }, + ] + + shareManager.selectUser(mockItem, mockInput) + + expect(shareManager.checkUserInGroup).toHaveBeenCalledWith( + "test@example.com", + { + email: "group:group-uuid", + name: "Test Group", + type: "group", + }, + mockInput, + "Test User", + ) + expect(shareManager.selectedUsersMap[mockInput.id]).toHaveLength(1) + }) + + test("should handle group selection with member count", () => { + mockItem.dataset.userType = "group" + mockItem.dataset.memberCount = "5" + + shareManager.selectUser(mockItem, mockInput) + + expect(shareManager.selectedUsersMap[mockInput.id][0]).toEqual({ + name: "Test User", + email: "test@example.com", + type: "group", + permission_level: "viewer", + member_count: 5, + }) + }) + }) + + describe("Share Methods", () => { + let shareManager + let mockAPIClient + + beforeEach(() => { + mockAPIClient = { + post: jest.fn(), + } + window.APIClient = mockAPIClient + + window.PermissionLevels = { + VIEWER: "viewer", + CONTRIBUTOR: "contributor", + CO_OWNER: "co-owner", + } + + document.getElementById = jest.fn((id) => { + if (id === "notify-users-checkbox-test-uuid") { + return { checked: false, value: "" } + } + return null + }) + + window.DOMUtils = { + showMessage: jest.fn(), + } + + window.location = { reload: jest.fn() } + + shareManager = new ShareActionManager({ + itemUuid: "test-uuid", + itemType: "dataset", + permissions: {}, + }) + + shareManager.selectedUsersMap = { + "user-search-test-uuid": [ + { email: "user1@example.com", permission_level: "viewer" }, + { + email: "user2@example.com", + permission_level: "contributor", + }, + ], + } + shareManager.pendingRemovals = new Set(["user3@example.com"]) + shareManager.pendingPermissionChanges = new Map([ + ["user1@example.com", "contributor"], + ]) + shareManager.closeModal = jest.fn() + }) + + test("should share item with multiple users and handle removals", async () => { + mockAPIClient.post.mockResolvedValue({ + success: true, + message: "Dataset shared successfully", + }) + + await shareManager.handleShareItem() + + expect(mockAPIClient.post).toHaveBeenCalledWith( + "/users/share-item/dataset/test-uuid/", + expect.objectContaining({ + "user-search": "user1@example.com,user2@example.com", + remove_users: JSON.stringify(["user3@example.com"]), + permission_changes: JSON.stringify([ + ["user1@example.com", "contributor"], + ]), + }), + ) + expect(window.DOMUtils.showMessage).toHaveBeenCalledWith( + "Dataset shared successfully", + expect.objectContaining({ + variant: "success", + placement: "toast", + presentation: "toast", + }), + ) + }) + + test("should include notification when checkbox is checked", async () => { + document.getElementById.mockReturnValue({ + checked: true, + value: "", + }) + + mockAPIClient.post.mockResolvedValue({ success: true }) + + await shareManager.handleShareItem() + + expect(mockAPIClient.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + notify_users: "1", + }), + ) + }) + }) + + describe("List refresh after share", () => { + let shareManager + let mockAPIClient + let reloadMock + + beforeEach(() => { + mockAPIClient = { post: jest.fn() } + window.APIClient = mockAPIClient + window.PermissionLevels = { VIEWER: "viewer" } + document.getElementById = jest.fn((id) => { + if (id === "notify-users-checkbox-test-uuid") { + return { checked: false, value: "" } + } + return null + }) + window.DOMUtils = { showMessage: jest.fn() } + reloadMock = jest.fn() + Object.defineProperty(window, "location", { + value: { reload: reloadMock }, + writable: true, + configurable: true, + }) + window.listRefreshManager = { + loadTable: jest.fn().mockResolvedValue("
"), + } + + shareManager = new ShareActionManager({ + itemUuid: "test-uuid", + itemType: "dataset", + permissions: {}, + }) + shareManager.selectedUsersMap = { + "user-search-test-uuid": [ + { email: "user1@example.com", permission_level: "viewer" }, + ], + } + shareManager.pendingRemovals = new Set() + shareManager.pendingPermissionChanges = new Map() + shareManager.closeModal = jest.fn() + shareManager.showToast = jest.fn() + }) + + test("should await listRefreshManager.loadTable after successful share", async () => { + mockAPIClient.post.mockResolvedValue({ success: true }) + + await shareManager.handleShareItem() + + expect(shareManager.closeModal).toHaveBeenCalledWith( + "shareModal-test-uuid", + ) + expect(window.listRefreshManager.loadTable).toHaveBeenCalled() + expect(reloadMock).not.toHaveBeenCalled() + }) + + test("should reload page when listRefreshManager is unavailable", async () => { + window.listRefreshManager = undefined + console.warn = jest.fn() + mockAPIClient.post.mockResolvedValue({ success: true }) + + await shareManager.handleShareItem() + + expect(console.warn).toHaveBeenCalledWith( + "listRefreshManager not available, reloading page", + ) + expect(reloadMock).toHaveBeenCalled() + }) + }) + + describe("Utility methods", () => { + beforeEach(() => { + window.DOMUtils.showMessage = jest.fn() + shareManager = new ShareActionManager(mockConfig) + }) + + test("getPermissionButtonText includes icon class and capitalized label", () => { + shareManager.permissions = { + getPermissionIcon: jest.fn(() => "bi-eye"), + } + + const text = shareManager.getPermissionButtonText("viewer") + + expect(text).toContain('class="bi bi-eye me-1"') + expect(text).toContain("Viewer") + expect( + shareManager.permissions.getPermissionIcon, + ).toHaveBeenCalledWith("viewer") + }) + + test("showToast routes success and danger through showMessage", () => { + shareManager.showToast("Saved", "success") + shareManager.showToast("Failed", "danger") + + expect(shareManager.showMessage).toHaveBeenCalledWith( + "Saved", + expect.objectContaining({ + variant: "success", + presentation: "toast", + }), + ) + expect(shareManager.showMessage).toHaveBeenCalledWith( + "Failed", + expect.objectContaining({ + variant: "danger", + presentation: "toast", + }), + ) + }) + }) + + describe("Error Handling", () => { + beforeEach(() => { + shareManager = new ShareActionManager(mockConfig) + }) + + test("should handle missing DOM elements gracefully", () => { + document.getElementById = jest.fn(() => null) + document.querySelector = jest.fn(() => null) + + // Mock bootstrap.Modal.getInstance to return null for missing modals + global.bootstrap.Modal.getInstance = jest.fn(() => null) + + expect(() => { + shareManager.closeModal() + shareManager.updateSaveButtonState("test-uuid") + }).not.toThrow() + }) + + test("searchUsers surfaces non-abort errors via displayError", async () => { + const mockDropdown = { innerHTML: "" } + shareManager.displayError = jest.fn() + global.APIClient.get.mockRejectedValue(new Error("API Error")) + + await shareManager.searchUsers("x", mockDropdown) + + expect(shareManager.displayError).toHaveBeenCalledWith(mockDropdown) + expect(shareManager.currentRequest).toBeNull() + }) + + test("should handle invalid data gracefully", () => { + expect(() => { + shareManager.handlePermissionLevelChange( + "test@example.com", + "read", + "test-uuid", + ) + }).not.toThrow() + }) + }) + + describe("State management", () => { + const itemUuid = "test-uuid" + + beforeEach(() => { + shareManager = new ShareActionManager(mockConfig) + }) + + test("clearSelections resets maps and pending change sets", () => { + shareManager.selectedUsersMap = { + [`user-search-${itemUuid}`]: [{ email: "a@b.com" }], + } + shareManager.pendingRemovals.add("a@b.com") + shareManager.pendingPermissionChanges.set("a@b.com", "viewer") + + shareManager.clearSelections() + + expect(shareManager.selectedUsersMap).toEqual({}) + expect(shareManager.pendingRemovals.size).toBe(0) + expect(shareManager.pendingPermissionChanges.size).toBe(0) + }) + + test("updateSaveButtonState disables save when no pending work", () => { + const saveBtn = { disabled: false } + document.getElementById = jest.fn((id) => + id === `share-item-btn-${itemUuid}` ? saveBtn : null, + ) + + shareManager.updateSaveButtonState(itemUuid) + + expect(saveBtn.disabled).toBe(true) + }) + + test("updateSaveButtonState enables save when users are selected", () => { + const saveBtn = { disabled: true } + document.getElementById = jest.fn((id) => + id === `share-item-btn-${itemUuid}` ? saveBtn : null, + ) + shareManager.selectedUsersMap[`user-search-${itemUuid}`] = [ + { email: "user@example.com" }, + ] + + shareManager.updateSaveButtonState(itemUuid) + + expect(saveBtn.disabled).toBe(false) + }) + }) +}) diff --git a/gateway/sds_gateway/static/js/actions/__tests__/VersioningActionManager.test.js b/gateway/sds_gateway/static/js/actions/__tests__/VersioningActionManager.test.js index 47c85ae64..c5e72582b 100644 --- a/gateway/sds_gateway/static/js/actions/__tests__/VersioningActionManager.test.js +++ b/gateway/sds_gateway/static/js/actions/__tests__/VersioningActionManager.test.js @@ -4,343 +4,351 @@ */ // Import the VersioningActionManager class -import { ModalManager } from "../../core/ModalManager.js"; -import { VersioningActionManager } from "../VersioningActionManager.js"; -import { flushMicrotasks } from "../../tests-config/testHelpers.js"; +import { ModalManager } from "../../core/ModalManager.js" +import { flushMicrotasks } from "../../tests-config/testHelpers.js" +import { VersioningActionManager } from "../VersioningActionManager.js" const { - setupVersioningActionTestEnvironment, - createVersionCreationClickEvent, -} = require("../../__tests__/helpers/actionTestMocks.js"); + setupVersioningActionTestEnvironment, + createVersionCreationClickEvent, +} = require("../../__tests__/helpers/actionTestMocks.js") describe("VersioningActionManager", () => { - let versioningManager; - let mockConfig; - let mockButton; - let mockPermissions; - - beforeEach(() => { - ({ mockConfig, mockPermissions, mockButton } = - setupVersioningActionTestEnvironment()); - }); - - describe("Initialization", () => { - test("should initialize with correct configuration", () => { - versioningManager = new VersioningActionManager(mockConfig); - - expect(versioningManager.permissions).toBe(mockPermissions); - expect(versioningManager.datasetUuid).toBe("test-dataset-uuid"); - expect(versioningManager.modalId).toBe( - "versioningModal-test-dataset-uuid", - ); - }); - - test("should setup event listeners on initialization", () => { - versioningManager = new VersioningActionManager(mockConfig); - - expect(mockButton.addEventListener).toHaveBeenCalledWith( - "click", - expect.any(Function), - ); - }); - - test("should prevent duplicate event listener attachment", () => { - mockButton.dataset.versionSetup = "true"; - mockButton.addEventListener.mockClear(); - - versioningManager = new VersioningActionManager(mockConfig); - - expect(mockButton.addEventListener).not.toHaveBeenCalled(); - }); - - test("should handle missing button gracefully", () => { - document.getElementById = jest.fn(() => null); - - expect(() => { - versioningManager = new VersioningActionManager(mockConfig); - }).not.toThrow(); - }); - }); - - describe("Version Creation", () => { - beforeEach(() => { - versioningManager = new VersioningActionManager(mockConfig); - jest.spyOn(ModalManager, "showModalLoading").mockResolvedValue(undefined); - jest.spyOn(versioningManager, "closeModal").mockImplementation(() => {}); - }); - - test("should handle version creation click", () => { - const event = createVersionCreationClickEvent(); - - // Get the click handler from addEventListener - const clickHandler = mockButton.addEventListener.mock.calls.find( - (call) => call[0] === "click", - )?.[1]; - - if (clickHandler) { - clickHandler(event); - } - - expect(event.preventDefault).toHaveBeenCalled(); - expect(event.stopPropagation).toHaveBeenCalled(); - }); - - test("should prevent double-submission", () => { - mockButton.dataset.processing = "true"; - - versioningManager.handleVersionCreation( - createVersionCreationClickEvent(), - mockButton, - ); - - expect(global.window.APIClient.post).not.toHaveBeenCalled(); - }); - - test("should show modal loading state", async () => { - await versioningManager.handleVersionCreation( - createVersionCreationClickEvent(), - mockButton, - ); - - expect(ModalManager.showModalLoading).toHaveBeenCalledWith( - "versioningModal-test-dataset-uuid", - ); - }); - - test("should disable button during processing", async () => { - await versioningManager.handleVersionCreation( - createVersionCreationClickEvent(), - mockButton, - ); - - expect(mockButton.disabled).toBe(true); - expect(mockButton.dataset.processing).toBe("true"); - }); - - test("should make API call to dataset-versioning endpoint", async () => { - await versioningManager.handleVersionCreation( - createVersionCreationClickEvent(), - mockButton, - ); - - expect(global.window.APIClient.post).toHaveBeenCalledWith( - "/users/dataset-versioning/", - { - dataset_uuid: "test-dataset-uuid", - copy_shared_users: false, - }, - ); - }); - - test("should handle successful version creation", async () => { - global.window.APIClient.post.mockResolvedValue({ - success: true, - version: 3, - }); - - await versioningManager.handleVersionCreation( - createVersionCreationClickEvent(), - mockButton, - ); - - // Wait for promises to resolve - await flushMicrotasks(); - - expect(versioningManager.closeModal).toHaveBeenCalledWith( - "versioningModal-test-dataset-uuid", - ); - expect(global.window.DOMUtils.showMessage).toHaveBeenCalledWith( - "Dataset version updated to v3 successfully", - expect.objectContaining({ - variant: "success", - placement: "toast", - presentation: "toast", - }), - ); - }); - - test("should refresh list on success", async () => { - global.window.APIClient.post.mockResolvedValue({ - success: true, - version: 2, - }); - - await versioningManager.handleVersionCreation( - createVersionCreationClickEvent(), - mockButton, - ); - - // Wait for promises to resolve - await flushMicrotasks(); - - expect(global.window.listRefreshManager.loadTable).toHaveBeenCalled(); - }); - - test("should fallback to page reload if listRefreshManager not available", async () => { - global.window.listRefreshManager = undefined; - const reloadMock = jest.fn(); - Object.defineProperty(global.window, "location", { - value: { reload: reloadMock }, - writable: true, - configurable: true, - }); - global.window.APIClient.post.mockResolvedValue({ - success: true, - version: 2, - }); - console.warn = jest.fn(); - - await versioningManager.handleVersionCreation( - createVersionCreationClickEvent(), - mockButton, - ); - - // Wait for promises to resolve - await flushMicrotasks(); - - expect(console.warn).toHaveBeenCalledWith( - "listRefreshManager not available, reloading page", - ); - expect(reloadMock).toHaveBeenCalled(); - }); - - test("should handle API error response", async () => { - global.window.APIClient.post.mockResolvedValue({ - success: false, - error: "Version creation failed", - }); - - await versioningManager.handleVersionCreation( - createVersionCreationClickEvent(), - mockButton, - ); - - // Wait for promises to resolve - await flushMicrotasks(); - - expect(global.window.DOMUtils.showMessage).toHaveBeenCalledWith( - "Version creation failed", - expect.objectContaining({ - variant: "danger", - placement: "toast", - presentation: "toast", - }), - ); - expect(versioningManager.closeModal).not.toHaveBeenCalled(); - }); - - test("should handle API exception", async () => { - global.window.APIClient.post.mockRejectedValue( - new Error("Network error"), - ); - - await versioningManager.handleVersionCreation( - createVersionCreationClickEvent(), - mockButton, - ); - - // Wait for promises to resolve - await flushMicrotasks(); - - expect(global.window.DOMUtils.showMessage).toHaveBeenCalledWith( - "Network error", - expect.objectContaining({ - variant: "danger", - placement: "toast", - presentation: "toast", - }), - ); - }); - - test("should re-enable button after processing", async () => { - await versioningManager.handleVersionCreation( - createVersionCreationClickEvent(), - mockButton, - ); - - // Wait for promises to resolve - await flushMicrotasks(); - - expect(mockButton.disabled).toBe(false); - expect(mockButton.dataset.processing).toBe("false"); - }); - - test("should re-enable button even on error", async () => { - global.window.APIClient.post.mockRejectedValue( - new Error("Network error"), - ); - - await versioningManager.handleVersionCreation( - createVersionCreationClickEvent(), - mockButton, - ); - - // Wait for promises to resolve - await flushMicrotasks(); - - expect(mockButton.disabled).toBe(false); - expect(mockButton.dataset.processing).toBe("false"); - }); - }); - - describe("Error Handling", () => { - beforeEach(() => { - versioningManager = new VersioningActionManager(mockConfig); - }); - - test("should handle missing button element", () => { - document.getElementById = jest.fn(() => null); - - expect(() => { - versioningManager.initializeVersionCreationButton(); - }).not.toThrow(); - }); - - test("should handle API errors with default message", async () => { - global.window.APIClient.post.mockRejectedValue( - new Error("Network error"), - ); - - await versioningManager.handleVersionCreation( - createVersionCreationClickEvent(), - mockButton, - ); - - // Wait for promises to resolve - await flushMicrotasks(); - - expect(global.window.DOMUtils.showMessage).toHaveBeenCalledWith( - expect.stringMatching(/Failed to create dataset version|Network error/), - expect.objectContaining({ - variant: "danger", - placement: "toast", - presentation: "toast", - }), - ); - }); - - test("should handle success response without version number", async () => { - global.window.listRefreshManager = { loadTable: jest.fn() }; - global.window.APIClient.post.mockResolvedValue({ - success: true, - }); - document.getElementById = jest.fn(() => null); - - await versioningManager.handleVersionCreation( - createVersionCreationClickEvent(), - mockButton, - ); - - // Wait for promises to resolve - await flushMicrotasks(); - - expect(global.window.DOMUtils.showMessage).toHaveBeenCalledWith( - expect.stringContaining("successfully"), - expect.objectContaining({ - variant: "success", - placement: "toast", - presentation: "toast", - }), - ); - }); - }); -}); + let versioningManager + let mockConfig + let mockButton + let mockPermissions + + beforeEach(() => { + ;({ mockConfig, mockPermissions, mockButton } = + setupVersioningActionTestEnvironment()) + }) + + describe("Initialization", () => { + test("should initialize with correct configuration", () => { + versioningManager = new VersioningActionManager(mockConfig) + + expect(versioningManager.permissions).toBe(mockPermissions) + expect(versioningManager.datasetUuid).toBe("test-dataset-uuid") + expect(versioningManager.modalId).toBe( + "versioningModal-test-dataset-uuid", + ) + }) + + test("should setup event listeners on initialization", () => { + versioningManager = new VersioningActionManager(mockConfig) + + expect(mockButton.addEventListener).toHaveBeenCalledWith( + "click", + expect.any(Function), + ) + }) + + test("should prevent duplicate event listener attachment", () => { + mockButton.dataset.versionSetup = "true" + mockButton.addEventListener.mockClear() + + versioningManager = new VersioningActionManager(mockConfig) + + expect(mockButton.addEventListener).not.toHaveBeenCalled() + }) + + test("should handle missing button gracefully", () => { + document.getElementById = jest.fn(() => null) + + expect(() => { + versioningManager = new VersioningActionManager(mockConfig) + }).not.toThrow() + }) + }) + + describe("Version Creation", () => { + beforeEach(() => { + versioningManager = new VersioningActionManager(mockConfig) + jest.spyOn(ModalManager, "showModalLoading").mockResolvedValue( + undefined, + ) + jest.spyOn(versioningManager, "closeModal").mockImplementation( + () => {}, + ) + }) + + test("should handle version creation click", () => { + const event = createVersionCreationClickEvent() + + // Get the click handler from addEventListener + const clickHandler = mockButton.addEventListener.mock.calls.find( + (call) => call[0] === "click", + )?.[1] + + if (clickHandler) { + clickHandler(event) + } + + expect(event.preventDefault).toHaveBeenCalled() + expect(event.stopPropagation).toHaveBeenCalled() + }) + + test("should prevent double-submission", () => { + mockButton.dataset.processing = "true" + + versioningManager.handleVersionCreation( + createVersionCreationClickEvent(), + mockButton, + ) + + expect(global.window.APIClient.post).not.toHaveBeenCalled() + }) + + test("should show modal loading state", async () => { + await versioningManager.handleVersionCreation( + createVersionCreationClickEvent(), + mockButton, + ) + + expect(ModalManager.showModalLoading).toHaveBeenCalledWith( + "versioningModal-test-dataset-uuid", + ) + }) + + test("should disable button during processing", async () => { + await versioningManager.handleVersionCreation( + createVersionCreationClickEvent(), + mockButton, + ) + + expect(mockButton.disabled).toBe(true) + expect(mockButton.dataset.processing).toBe("true") + }) + + test("should make API call to dataset-versioning endpoint", async () => { + await versioningManager.handleVersionCreation( + createVersionCreationClickEvent(), + mockButton, + ) + + expect(global.window.APIClient.post).toHaveBeenCalledWith( + "/users/dataset-versioning/", + { + dataset_uuid: "test-dataset-uuid", + copy_shared_users: false, + }, + ) + }) + + test("should handle successful version creation", async () => { + global.window.APIClient.post.mockResolvedValue({ + success: true, + version: 3, + }) + + await versioningManager.handleVersionCreation( + createVersionCreationClickEvent(), + mockButton, + ) + + // Wait for promises to resolve + await flushMicrotasks() + + expect(versioningManager.closeModal).toHaveBeenCalledWith( + "versioningModal-test-dataset-uuid", + ) + expect(global.window.DOMUtils.showMessage).toHaveBeenCalledWith( + "Dataset version updated to v3 successfully", + expect.objectContaining({ + variant: "success", + placement: "toast", + presentation: "toast", + }), + ) + }) + + test("should refresh list on success", async () => { + global.window.APIClient.post.mockResolvedValue({ + success: true, + version: 2, + }) + + await versioningManager.handleVersionCreation( + createVersionCreationClickEvent(), + mockButton, + ) + + // Wait for promises to resolve + await flushMicrotasks() + + expect( + global.window.listRefreshManager.loadTable, + ).toHaveBeenCalled() + }) + + test("should fallback to page reload if listRefreshManager not available", async () => { + global.window.listRefreshManager = undefined + const reloadMock = jest.fn() + Object.defineProperty(global.window, "location", { + value: { reload: reloadMock }, + writable: true, + configurable: true, + }) + global.window.APIClient.post.mockResolvedValue({ + success: true, + version: 2, + }) + console.warn = jest.fn() + + await versioningManager.handleVersionCreation( + createVersionCreationClickEvent(), + mockButton, + ) + + // Wait for promises to resolve + await flushMicrotasks() + + expect(console.warn).toHaveBeenCalledWith( + "listRefreshManager not available, reloading page", + ) + expect(reloadMock).toHaveBeenCalled() + }) + + test("should handle API error response", async () => { + global.window.APIClient.post.mockResolvedValue({ + success: false, + error: "Version creation failed", + }) + + await versioningManager.handleVersionCreation( + createVersionCreationClickEvent(), + mockButton, + ) + + // Wait for promises to resolve + await flushMicrotasks() + + expect(global.window.DOMUtils.showMessage).toHaveBeenCalledWith( + "Version creation failed", + expect.objectContaining({ + variant: "danger", + placement: "toast", + presentation: "toast", + }), + ) + expect(versioningManager.closeModal).not.toHaveBeenCalled() + }) + + test("should handle API exception", async () => { + global.window.APIClient.post.mockRejectedValue( + new Error("Network error"), + ) + + await versioningManager.handleVersionCreation( + createVersionCreationClickEvent(), + mockButton, + ) + + // Wait for promises to resolve + await flushMicrotasks() + + expect(global.window.DOMUtils.showMessage).toHaveBeenCalledWith( + "Network error", + expect.objectContaining({ + variant: "danger", + placement: "toast", + presentation: "toast", + }), + ) + }) + + test("should re-enable button after processing", async () => { + await versioningManager.handleVersionCreation( + createVersionCreationClickEvent(), + mockButton, + ) + + // Wait for promises to resolve + await flushMicrotasks() + + expect(mockButton.disabled).toBe(false) + expect(mockButton.dataset.processing).toBe("false") + }) + + test("should re-enable button even on error", async () => { + global.window.APIClient.post.mockRejectedValue( + new Error("Network error"), + ) + + await versioningManager.handleVersionCreation( + createVersionCreationClickEvent(), + mockButton, + ) + + // Wait for promises to resolve + await flushMicrotasks() + + expect(mockButton.disabled).toBe(false) + expect(mockButton.dataset.processing).toBe("false") + }) + }) + + describe("Error Handling", () => { + beforeEach(() => { + versioningManager = new VersioningActionManager(mockConfig) + }) + + test("should handle missing button element", () => { + document.getElementById = jest.fn(() => null) + + expect(() => { + versioningManager.initializeVersionCreationButton() + }).not.toThrow() + }) + + test("should handle API errors with default message", async () => { + global.window.APIClient.post.mockRejectedValue( + new Error("Network error"), + ) + + await versioningManager.handleVersionCreation( + createVersionCreationClickEvent(), + mockButton, + ) + + // Wait for promises to resolve + await flushMicrotasks() + + expect(global.window.DOMUtils.showMessage).toHaveBeenCalledWith( + expect.stringMatching( + /Failed to create dataset version|Network error/, + ), + expect.objectContaining({ + variant: "danger", + placement: "toast", + presentation: "toast", + }), + ) + }) + + test("should handle success response without version number", async () => { + global.window.listRefreshManager = { loadTable: jest.fn() } + global.window.APIClient.post.mockResolvedValue({ + success: true, + }) + document.getElementById = jest.fn(() => null) + + await versioningManager.handleVersionCreation( + createVersionCreationClickEvent(), + mockButton, + ) + + // Wait for promises to resolve + await flushMicrotasks() + + expect(global.window.DOMUtils.showMessage).toHaveBeenCalledWith( + expect.stringContaining("successfully"), + expect.objectContaining({ + variant: "success", + placement: "toast", + presentation: "toast", + }), + ) + }) + }) +}) diff --git a/gateway/sds_gateway/static/js/actions/__tests__/quickAddApi.test.js b/gateway/sds_gateway/static/js/actions/__tests__/quickAddApi.test.js index e23510022..ac07f32f1 100644 --- a/gateway/sds_gateway/static/js/actions/__tests__/quickAddApi.test.js +++ b/gateway/sds_gateway/static/js/actions/__tests__/quickAddApi.test.js @@ -1,29 +1,29 @@ const { - formatQuickAddSummary, - postQuickAddCapture, -} = require("../quickAdd/quickAddApi.js"); + formatQuickAddSummary, + postQuickAddCapture, +} = require("../quickAdd/quickAddApi.js") describe("quickAddApi", () => { - test("formatQuickAddSummary builds message parts", () => { - expect(formatQuickAddSummary(2, 1, 0, null)).toBe( - "2 added, 1 already in dataset.", - ); - expect(formatQuickAddSummary(0, 0, 1, "Network error")).toBe( - "1 failed, : Network error.", - ); - }); + test("formatQuickAddSummary builds message parts", () => { + expect(formatQuickAddSummary(2, 1, 0, null)).toBe( + "2 added, 1 already in dataset.", + ) + expect(formatQuickAddSummary(0, 0, 1, "Network error")).toBe( + "1 failed, : Network error.", + ) + }) - test("postQuickAddCapture maps APIClient response", async () => { - global.APIClient = { - post: jest.fn().mockResolvedValue({ - success: true, - added: [{ id: 1 }], - skipped: [], - errors: [], - }), - }; - const result = await postQuickAddCapture("/quick-add/", "ds-1", "cap-1"); - expect(result.success).toBe(true); - expect(result.added).toBe(1); - }); -}); + test("postQuickAddCapture maps APIClient response", async () => { + global.APIClient = { + post: jest.fn().mockResolvedValue({ + success: true, + added: [{ id: 1 }], + skipped: [], + errors: [], + }), + } + const result = await postQuickAddCapture("/quick-add/", "ds-1", "cap-1") + expect(result.success).toBe(true) + expect(result.added).toBe(1) + }) +}) diff --git a/gateway/sds_gateway/static/js/actions/details/AssetDetailsModalLoader.js b/gateway/sds_gateway/static/js/actions/details/AssetDetailsModalLoader.js index bfa165283..b27db49e5 100644 --- a/gateway/sds_gateway/static/js/actions/details/AssetDetailsModalLoader.js +++ b/gateway/sds_gateway/static/js/actions/details/AssetDetailsModalLoader.js @@ -2,167 +2,165 @@ * Registry-driven asset details modal (fetch HTML + inject). */ class AssetDetailsModalLoader { - /** - * @param {HTMLElement} startEl - * @param {string[]} selectors - * @returns {HTMLElement | null} - */ - static findDelegateTarget(startEl, selectors) { - if (!startEl || !selectors?.length) return null; - for (const sel of selectors) { - if (startEl.matches?.(sel)) return startEl; - const closest = startEl.closest?.(sel); - if (closest) return closest; - } - return null; - } + /** + * @param {HTMLElement} startEl + * @param {string[]} selectors + * @returns {HTMLElement | null} + */ + static findDelegateTarget(startEl, selectors) { + if (!startEl || !selectors?.length) return null + for (const sel of selectors) { + if (startEl.matches?.(sel)) return startEl + const closest = startEl.closest?.(sel) + if (closest) return closest + } + return null + } - /** - * @param {HTMLElement} startEl - * @returns {{ cfg: object, target: HTMLElement } | null} - */ - static resolveDetailsModalFromTrigger(startEl) { - const registry = window.DetailsModalAssetRegistry; - if (!registry || !startEl) return null; - for (const key of Object.keys(registry)) { - const cfg = registry[key]; - const selectors = cfg.delegateClickSelectors || []; - const target = AssetDetailsModalLoader.findDelegateTarget( - startEl, - selectors, - ); - if (target) return { cfg, target }; - } - return null; - } + /** + * @param {HTMLElement} startEl + * @returns {{ cfg: object, target: HTMLElement } | null} + */ + static resolveDetailsModalFromTrigger(startEl) { + const registry = window.DetailsModalAssetRegistry + if (!registry || !startEl) return null + for (const key of Object.keys(registry)) { + const cfg = registry[key] + const selectors = cfg.delegateClickSelectors || [] + const target = AssetDetailsModalLoader.findDelegateTarget( + startEl, + selectors, + ) + if (target) return { cfg, target } + } + return null + } - /** - * @param {HTMLElement} startEl - */ - static async openDetailsFromTrigger(startEl) { - const resolved = AssetDetailsModalLoader.resolveDetailsModalFromTrigger( - startEl, - ); - if (!resolved) return; + /** + * @param {HTMLElement} startEl + */ + static async openDetailsFromTrigger(startEl) { + const resolved = + AssetDetailsModalLoader.resolveDetailsModalFromTrigger(startEl) + if (!resolved) return - const { cfg, target } = resolved; - const uuid = cfg.resolveUuidFromTrigger(target); - if (!uuid || uuid === "null" || uuid === "undefined") { - console.warn("No valid UUID for details modal:", target); - return; - } + const { cfg, target } = resolved + const uuid = cfg.resolveUuidFromTrigger(target) + if (!uuid || uuid === "null" || uuid === "undefined") { + console.warn("No valid UUID for details modal:", target) + return + } - const shell = - typeof cfg.resolveShell === "function" ? cfg.resolveShell() : null; - if (!shell?.modal || !shell.bodyEl) { - console.warn("Details modal shell not found:", cfg.assetType, uuid); - return; - } + const shell = + typeof cfg.resolveShell === "function" ? cfg.resolveShell() : null + if (!shell?.modal || !shell.bodyEl) { + console.warn("Details modal shell not found:", cfg.assetType, uuid) + return + } - const { modal, titleEl, bodyEl } = shell; + const { modal, titleEl, bodyEl } = shell - if (cfg.assetType === "capture") { - const visualizeBtn = document.getElementById("visualize-btn"); - visualizeBtn?.classList.add("d-none"); - } + if (cfg.assetType === "capture") { + const visualizeBtn = document.getElementById("visualize-btn") + visualizeBtn?.classList.add("d-none") + } - const loadingTitle = cfg.loadingTitle || "Loading..."; - if (titleEl) { - titleEl.textContent = loadingTitle; - } - bodyEl.innerHTML = ` + const loadingTitle = cfg.loadingTitle || "Loading..." + if (titleEl) { + titleEl.textContent = loadingTitle + } + bodyEl.innerHTML = `
${loadingTitle}
-
`; + ` - const bsModal = window.ModalManager?.getOrCreateBootstrapModal?.(modal); - if (bsModal) { - bsModal.show(); - } + const bsModal = window.ModalManager?.getOrCreateBootstrapModal?.(modal) + if (bsModal) { + bsModal.show() + } - try { - const response = await fetch(cfg.buildDetailsUrl(uuid), { - credentials: "same-origin", - headers: { Accept: "application/json" }, - }); - if (!response.ok) { - throw new Error(`HTTP error ${response.status}`); - } - const data = await response.json(); + try { + const response = await fetch(cfg.buildDetailsUrl(uuid), { + credentials: "same-origin", + headers: { Accept: "application/json" }, + }) + if (!response.ok) { + throw new Error(`HTTP error ${response.status}`) + } + const data = await response.json() - if (titleEl) { - titleEl.textContent = data.title || loadingTitle; - } - bodyEl.innerHTML = data.html || ""; + if (titleEl) { + titleEl.textContent = data.title || loadingTitle + } + bodyEl.innerHTML = data.html || "" - const meta = data.meta || {}; + const meta = data.meta || {} - if (typeof cfg.afterInject === "function") { - cfg.afterInject({ modal, meta, uuid, cfg }); - } - } catch (error) { - console.error("Error opening details modal:", error); - const errTitle = "Error"; - const errBody = - cfg.assetType === "capture" - ? "Error displaying capture details" - : "Error displaying dataset details"; - if (titleEl) { - titleEl.textContent = errTitle; - } - bodyEl.innerHTML = `

${errBody}

`; - } - } + if (typeof cfg.afterInject === "function") { + cfg.afterInject({ modal, meta, uuid, cfg }) + } + } catch (error) { + console.error("Error opening details modal:", error) + const errTitle = "Error" + const errBody = + cfg.assetType === "capture" + ? "Error displaying capture details" + : "Error displaying dataset details" + if (titleEl) { + titleEl.textContent = errTitle + } + bodyEl.innerHTML = `

${errBody}

` + } + } - /** - * @returns {() => void} cleanup - */ - static attachDocumentDetailsClickDelegation() { - const handler = (e) => { - if ( - e.target.matches('[data-bs-toggle="dropdown"]') || - e.target.closest('[data-bs-toggle="dropdown"]') - ) { - return; - } - const resolved = AssetDetailsModalLoader.resolveDetailsModalFromTrigger( - e.target, - ); - if (!resolved) return; - e.preventDefault(); - const { target } = resolved; - void AssetDetailsModalLoader.openDetailsFromTrigger(target); - }; - document.addEventListener("click", handler); - return () => { - document.removeEventListener("click", handler); - if (typeof document !== "undefined" && document.body) { - document.body.dataset.detailsAssetClickWired = ""; - } - }; - } + /** + * @returns {() => void} cleanup + */ + static attachDocumentDetailsClickDelegation() { + const handler = (e) => { + if ( + e.target.matches('[data-bs-toggle="dropdown"]') || + e.target.closest('[data-bs-toggle="dropdown"]') + ) { + return + } + const resolved = + AssetDetailsModalLoader.resolveDetailsModalFromTrigger(e.target) + if (!resolved) return + e.preventDefault() + const { target } = resolved + void AssetDetailsModalLoader.openDetailsFromTrigger(target) + } + document.addEventListener("click", handler) + return () => { + document.removeEventListener("click", handler) + if (typeof document !== "undefined" && document.body) { + document.body.dataset.detailsAssetClickWired = "" + } + } + } - /** - * Idempotent global details click delegation. - * @returns {() => void} cleanup - */ - static ensureDetailsClickDelegation() { - if ( - typeof document === "undefined" || - document.body?.dataset?.detailsAssetClickWired === "1" - ) { - return () => {}; - } - document.body.dataset.detailsAssetClickWired = "1"; - return AssetDetailsModalLoader.attachDocumentDetailsClickDelegation(); - } + /** + * Idempotent global details click delegation. + * @returns {() => void} cleanup + */ + static ensureDetailsClickDelegation() { + if ( + typeof document === "undefined" || + document.body?.dataset?.detailsAssetClickWired === "1" + ) { + return () => {} + } + document.body.dataset.detailsAssetClickWired = "1" + return AssetDetailsModalLoader.attachDocumentDetailsClickDelegation() + } } if (typeof window !== "undefined") { - window.AssetDetailsModalLoader = AssetDetailsModalLoader; + window.AssetDetailsModalLoader = AssetDetailsModalLoader } if (typeof module !== "undefined" && module.exports) { - module.exports = { AssetDetailsModalLoader }; + module.exports = { AssetDetailsModalLoader } } diff --git a/gateway/sds_gateway/static/js/actions/details/CaptureDetailsModalBehavior.js b/gateway/sds_gateway/static/js/actions/details/CaptureDetailsModalBehavior.js index 524c37782..72a6b6f69 100644 --- a/gateway/sds_gateway/static/js/actions/details/CaptureDetailsModalBehavior.js +++ b/gateway/sds_gateway/static/js/actions/details/CaptureDetailsModalBehavior.js @@ -2,249 +2,264 @@ * Capture-specific asset details modal behavior (name edit, visualize button). */ class CaptureDetailsModalBehavior { - /** - * @param {{ modal: HTMLElement, meta: object, uuid: string }} ctx - */ - static afterInject(ctx) { - const { modal, meta } = ctx; - const visualizeBtn = document.getElementById("visualize-btn"); - visualizeBtn?.classList.add("d-none"); - - CaptureDetailsModalBehavior.setupVisualizeFromMeta(meta); - CaptureDetailsModalBehavior.ensureDelegatedCaptureNameEditing(modal); - } - - /** - * @param {{ visualize_enabled?: boolean, uuid?: string, capture_type?: string }} meta - */ - static setupVisualizeFromMeta(meta) { - const visualizeBtn = document.getElementById("visualize-btn"); - if (!visualizeBtn || !meta) return; - - if (meta.visualize_enabled) { - visualizeBtn.classList.remove("d-none"); - visualizeBtn.dataset.captureUuid = meta.uuid || ""; - visualizeBtn.dataset.captureType = meta.capture_type || ""; - visualizeBtn.onclick = (e) => { - e?.preventDefault?.(); - if ( - !window.visualizationModalInstance && - window.VisualizationModal - ) { - window.visualizationModalInstance = new window.VisualizationModal(); - } - if (window.visualizationModalInstance) { - window.visualizationModalInstance.openWithCaptureData( - meta.uuid, - meta.capture_type, - ); - } - }; - } else { - visualizeBtn.classList.add("d-none"); - } - } - - /** - * @param {HTMLElement} modalEl - */ - static ensureDelegatedCaptureNameEditing(modalEl) { - const modal = modalEl; - if (!modal) return; - - if ( - modal.dataset.nameDelegationWired === "1" && - !modal.querySelector("#capture-name-input") - ) { - delete modal.dataset.nameDelegationWired; - } - if (modal.dataset.nameDelegationWired === "1") { - return; - } - modal.dataset.nameDelegationWired = "1"; - - const state = { original: "", isEditing: false }; - - const getControls = () => ({ - nameInput: modal.querySelector("#capture-name-input"), - editBtn: modal.querySelector("#edit-name-btn"), - saveBtn: modal.querySelector("#save-name-btn"), - cancelBtn: modal.querySelector("#cancel-name-btn"), - }); - - const stopEditing = (controls) => { - if (!controls.nameInput) return; - controls.nameInput.disabled = true; - controls.editBtn?.classList.remove("d-none"); - controls.saveBtn?.classList.add("d-none"); - controls.cancelBtn?.classList.add("d-none"); - }; - - const startEditing = (controls) => { - if (!controls.nameInput) return; - controls.nameInput.disabled = false; - controls.nameInput.focus(); - controls.nameInput.select(); - controls.editBtn?.classList.add("d-none"); - controls.saveBtn?.classList.remove("d-none"); - controls.cancelBtn?.classList.remove("d-none"); - }; - - const titleEl = - document.getElementById("asset-details-modal-label") || - modal.querySelector(".modal-title"); - - modal.addEventListener("click", async (e) => { - const controls = getControls(); - if (!controls.nameInput || !controls.editBtn) return; - - const t = e.target; - - if (t.closest("#edit-name-btn")) { - e.preventDefault(); - if (!state.isEditing) { - state.original = controls.nameInput.value; - startEditing(controls); - state.isEditing = true; - } - return; - } - - if (t.closest("#cancel-name-btn")) { - e.preventDefault(); - controls.nameInput.value = state.original; - stopEditing(controls); - state.isEditing = false; - return; - } - - if (t.closest("#save-name-btn")) { - e.preventDefault(); - const newName = controls.nameInput.value.trim(); - const uuid = controls.nameInput.getAttribute("data-uuid"); - if (!uuid) return; - - controls.editBtn.disabled = true; - controls.saveBtn.disabled = true; - controls.cancelBtn.disabled = true; - controls.saveBtn.innerHTML = - ''; - - try { - await CaptureDetailsModalBehavior.updateCaptureName(uuid, newName); - state.original = newName; - stopEditing(controls); - state.isEditing = false; - CaptureDetailsModalBehavior.updateTableNameDisplay(uuid, newName); - if (titleEl) { - titleEl.textContent = newName || "Unnamed Capture"; - } - const modalBody = document.getElementById("asset-details-modal-body"); - if (modalBody && window.DOMUtils?.showMessage) { - window.DOMUtils.clearAlerts?.(modalBody); - await window.DOMUtils.showMessage( - "Capture name updated successfully!", - { - variant: "success", - placement: "append", - target: modalBody, - presentation: "alert", - templateContext: { - alert_type: "success", - icon: "check-circle", - dismissible: true, - }, - autoRemove: true, - autoRemoveMs: 3000, - }, - ); - } - } catch (err) { - console.error("Error updating capture name:", err); - const modalBody = document.getElementById("asset-details-modal-body"); - if (modalBody && window.DOMUtils?.showMessage) { - window.DOMUtils.clearAlerts?.(modalBody); - await window.DOMUtils.showMessage( - "Failed to update capture name. Please try again.", - { - variant: "danger", - placement: "append", - target: modalBody, - presentation: "alert", - templateContext: { - alert_type: "danger", - icon: "exclamation-triangle", - dismissible: true, - }, - autoRemove: true, - autoRemoveMs: 5000, - }, - ); - } - controls.nameInput.value = state.original; - } finally { - controls.editBtn.disabled = false; - controls.saveBtn.disabled = false; - controls.cancelBtn.disabled = false; - controls.saveBtn.innerHTML = ''; - } - } - }); - - modal.addEventListener("keypress", (e) => { - if (e.target.id !== "capture-name-input") return; - if (e.key === "Enter" && !e.target.disabled) { - modal.querySelector("#save-name-btn")?.click(); - } - }); - - modal.addEventListener("keydown", (e) => { - if (e.target.id !== "capture-name-input") return; - if (e.key === "Escape" && !e.target.disabled) { - const controls = getControls(); - controls.nameInput.value = state.original; - stopEditing(controls); - state.isEditing = false; - } - }); - } - - static async updateCaptureName(uuid, newName) { - const response = await fetch(`/api/v1/assets/captures/${uuid}/`, { - method: "PATCH", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": new window.APIClient().getCSRFToken(), - }, - body: JSON.stringify({ name: newName }), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.detail || "Failed to update capture name"); - } - - return response.json(); - } - - static updateTableNameDisplay(uuid, newName) { - for (const link of document.querySelectorAll(`[data-uuid="${uuid}"]`)) { - link.dataset.name = newName; - if (link.classList.contains("capture-link")) { - link.textContent = newName || "Unnamed Capture"; - link.setAttribute( - "aria-label", - `View details for capture ${newName || uuid}`, - ); - link.setAttribute("title", `View capture details: ${newName || uuid}`); - } - } - } + /** + * @param {{ modal: HTMLElement, meta: object, uuid: string }} ctx + */ + static afterInject(ctx) { + const { modal, meta } = ctx + const visualizeBtn = document.getElementById("visualize-btn") + visualizeBtn?.classList.add("d-none") + + CaptureDetailsModalBehavior.setupVisualizeFromMeta(meta) + CaptureDetailsModalBehavior.ensureDelegatedCaptureNameEditing(modal) + } + + /** + * @param {{ visualize_enabled?: boolean, uuid?: string, capture_type?: string }} meta + */ + static setupVisualizeFromMeta(meta) { + const visualizeBtn = document.getElementById("visualize-btn") + if (!visualizeBtn || !meta) return + + if (meta.visualize_enabled) { + visualizeBtn.classList.remove("d-none") + visualizeBtn.dataset.captureUuid = meta.uuid || "" + visualizeBtn.dataset.captureType = meta.capture_type || "" + visualizeBtn.onclick = (e) => { + e?.preventDefault?.() + if ( + !window.visualizationModalInstance && + window.VisualizationModal + ) { + window.visualizationModalInstance = + new window.VisualizationModal() + } + if (window.visualizationModalInstance) { + window.visualizationModalInstance.openWithCaptureData( + meta.uuid, + meta.capture_type, + ) + } + } + } else { + visualizeBtn.classList.add("d-none") + } + } + + /** + * @param {HTMLElement} modalEl + */ + static ensureDelegatedCaptureNameEditing(modalEl) { + const modal = modalEl + if (!modal) return + + if ( + modal.dataset.nameDelegationWired === "1" && + !modal.querySelector("#capture-name-input") + ) { + delete modal.dataset.nameDelegationWired + } + if (modal.dataset.nameDelegationWired === "1") { + return + } + modal.dataset.nameDelegationWired = "1" + + const state = { original: "", isEditing: false } + + const getControls = () => ({ + nameInput: modal.querySelector("#capture-name-input"), + editBtn: modal.querySelector("#edit-name-btn"), + saveBtn: modal.querySelector("#save-name-btn"), + cancelBtn: modal.querySelector("#cancel-name-btn"), + }) + + const stopEditing = (controls) => { + if (!controls.nameInput) return + controls.nameInput.disabled = true + controls.editBtn?.classList.remove("d-none") + controls.saveBtn?.classList.add("d-none") + controls.cancelBtn?.classList.add("d-none") + } + + const startEditing = (controls) => { + if (!controls.nameInput) return + controls.nameInput.disabled = false + controls.nameInput.focus() + controls.nameInput.select() + controls.editBtn?.classList.add("d-none") + controls.saveBtn?.classList.remove("d-none") + controls.cancelBtn?.classList.remove("d-none") + } + + const titleEl = + document.getElementById("asset-details-modal-label") || + modal.querySelector(".modal-title") + + modal.addEventListener("click", async (e) => { + const controls = getControls() + if (!controls.nameInput || !controls.editBtn) return + + const t = e.target + + if (t.closest("#edit-name-btn")) { + e.preventDefault() + if (!state.isEditing) { + state.original = controls.nameInput.value + startEditing(controls) + state.isEditing = true + } + return + } + + if (t.closest("#cancel-name-btn")) { + e.preventDefault() + controls.nameInput.value = state.original + stopEditing(controls) + state.isEditing = false + return + } + + if (t.closest("#save-name-btn")) { + e.preventDefault() + const newName = controls.nameInput.value.trim() + const uuid = controls.nameInput.getAttribute("data-uuid") + if (!uuid) return + + controls.editBtn.disabled = true + controls.saveBtn.disabled = true + controls.cancelBtn.disabled = true + controls.saveBtn.innerHTML = + '' + + try { + await CaptureDetailsModalBehavior.updateCaptureName( + uuid, + newName, + ) + state.original = newName + stopEditing(controls) + state.isEditing = false + CaptureDetailsModalBehavior.updateTableNameDisplay( + uuid, + newName, + ) + if (titleEl) { + titleEl.textContent = newName || "Unnamed Capture" + } + const modalBody = document.getElementById( + "asset-details-modal-body", + ) + if (modalBody && window.DOMUtils?.showMessage) { + window.DOMUtils.clearAlerts?.(modalBody) + await window.DOMUtils.showMessage( + "Capture name updated successfully!", + { + variant: "success", + placement: "append", + target: modalBody, + presentation: "alert", + templateContext: { + alert_type: "success", + icon: "check-circle", + dismissible: true, + }, + autoRemove: true, + autoRemoveMs: 3000, + }, + ) + } + } catch (err) { + console.error("Error updating capture name:", err) + const modalBody = document.getElementById( + "asset-details-modal-body", + ) + if (modalBody && window.DOMUtils?.showMessage) { + window.DOMUtils.clearAlerts?.(modalBody) + await window.DOMUtils.showMessage( + "Failed to update capture name. Please try again.", + { + variant: "danger", + placement: "append", + target: modalBody, + presentation: "alert", + templateContext: { + alert_type: "danger", + icon: "exclamation-triangle", + dismissible: true, + }, + autoRemove: true, + autoRemoveMs: 5000, + }, + ) + } + controls.nameInput.value = state.original + } finally { + controls.editBtn.disabled = false + controls.saveBtn.disabled = false + controls.cancelBtn.disabled = false + controls.saveBtn.innerHTML = + '' + } + } + }) + + modal.addEventListener("keypress", (e) => { + if (e.target.id !== "capture-name-input") return + if (e.key === "Enter" && !e.target.disabled) { + modal.querySelector("#save-name-btn")?.click() + } + }) + + modal.addEventListener("keydown", (e) => { + if (e.target.id !== "capture-name-input") return + if (e.key === "Escape" && !e.target.disabled) { + const controls = getControls() + controls.nameInput.value = state.original + stopEditing(controls) + state.isEditing = false + } + }) + } + + static async updateCaptureName(uuid, newName) { + const response = await fetch(`/api/v1/assets/captures/${uuid}/`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": new window.APIClient().getCSRFToken(), + }, + body: JSON.stringify({ name: newName }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.detail || "Failed to update capture name") + } + + return response.json() + } + + static updateTableNameDisplay(uuid, newName) { + for (const link of document.querySelectorAll(`[data-uuid="${uuid}"]`)) { + link.dataset.name = newName + if (link.classList.contains("capture-link")) { + link.textContent = newName || "Unnamed Capture" + link.setAttribute( + "aria-label", + `View details for capture ${newName || uuid}`, + ) + link.setAttribute( + "title", + `View capture details: ${newName || uuid}`, + ) + } + } + } } if (typeof window !== "undefined") { - window.CaptureDetailsModalBehavior = CaptureDetailsModalBehavior; + window.CaptureDetailsModalBehavior = CaptureDetailsModalBehavior } if (typeof module !== "undefined" && module.exports) { - module.exports = { CaptureDetailsModalBehavior }; + module.exports = { CaptureDetailsModalBehavior } } diff --git a/gateway/sds_gateway/static/js/actions/details/__tests__/AssetDetailsModalLoader.test.js b/gateway/sds_gateway/static/js/actions/details/__tests__/AssetDetailsModalLoader.test.js index cfc45127c..717918560 100644 --- a/gateway/sds_gateway/static/js/actions/details/__tests__/AssetDetailsModalLoader.test.js +++ b/gateway/sds_gateway/static/js/actions/details/__tests__/AssetDetailsModalLoader.test.js @@ -2,289 +2,302 @@ * Jest tests for AssetDetailsModalLoader */ -import { AssetDetailsModalLoader } from "../AssetDetailsModalLoader.js"; -const { createMockFetchResponse } = require("../../../tests-config/testHelpers.js"); +import { AssetDetailsModalLoader } from "../AssetDetailsModalLoader.js" +const { + createMockFetchResponse, +} = require("../../../tests-config/testHelpers.js") describe("AssetDetailsModalLoader", () => { - beforeEach(() => { - jest.clearAllMocks(); - document.body.innerHTML = ""; - delete window.DetailsModalAssetRegistry; - }); + beforeEach(() => { + jest.clearAllMocks() + document.body.innerHTML = "" + delete window.DetailsModalAssetRegistry + }) - describe("findDelegateTarget", () => { - test("returns element when it matches selector", () => { - const el = document.createElement("button"); - el.className = "details-trigger"; - expect( - AssetDetailsModalLoader.findDelegateTarget(el, [".details-trigger"]), - ).toBe(el); - }); + describe("findDelegateTarget", () => { + test("returns element when it matches selector", () => { + const el = document.createElement("button") + el.className = "details-trigger" + expect( + AssetDetailsModalLoader.findDelegateTarget(el, [ + ".details-trigger", + ]), + ).toBe(el) + }) - test("returns closest ancestor match", () => { - const parent = document.createElement("a"); - parent.className = "capture-link"; - const child = document.createElement("span"); - parent.appendChild(child); - expect( - AssetDetailsModalLoader.findDelegateTarget(child, [".capture-link"]), - ).toBe(parent); - }); + test("returns closest ancestor match", () => { + const parent = document.createElement("a") + parent.className = "capture-link" + const child = document.createElement("span") + parent.appendChild(child) + expect( + AssetDetailsModalLoader.findDelegateTarget(child, [ + ".capture-link", + ]), + ).toBe(parent) + }) - test("returns null when no match", () => { - const el = document.createElement("div"); - expect( - AssetDetailsModalLoader.findDelegateTarget(el, [".missing"]), - ).toBeNull(); - }); - }); + test("returns null when no match", () => { + const el = document.createElement("div") + expect( + AssetDetailsModalLoader.findDelegateTarget(el, [".missing"]), + ).toBeNull() + }) + }) - describe("resolveDetailsModalFromTrigger", () => { - test("returns cfg and target from registry", () => { - const trigger = document.createElement("button"); - trigger.dataset.uuid = "cap-1"; - trigger.className = "open-capture-details"; - window.DetailsModalAssetRegistry = { - capture: { - delegateClickSelectors: [".open-capture-details"], - resolveUuidFromTrigger: (t) => t.dataset.uuid, - }, - }; + describe("resolveDetailsModalFromTrigger", () => { + test("returns cfg and target from registry", () => { + const trigger = document.createElement("button") + trigger.dataset.uuid = "cap-1" + trigger.className = "open-capture-details" + window.DetailsModalAssetRegistry = { + capture: { + delegateClickSelectors: [".open-capture-details"], + resolveUuidFromTrigger: (t) => t.dataset.uuid, + }, + } - const resolved = - AssetDetailsModalLoader.resolveDetailsModalFromTrigger(trigger); + const resolved = + AssetDetailsModalLoader.resolveDetailsModalFromTrigger(trigger) - expect(resolved?.target).toBe(trigger); - expect(resolved?.cfg.delegateClickSelectors).toContain( - ".open-capture-details", - ); - }); - }); + expect(resolved?.target).toBe(trigger) + expect(resolved?.cfg.delegateClickSelectors).toContain( + ".open-capture-details", + ) + }) + }) - describe("openDetailsFromTrigger", () => { - test("fetches details and calls afterInject on success", async () => { - const modal = document.createElement("div"); - const bodyEl = document.createElement("div"); - const titleEl = document.createElement("h5"); - const trigger = document.createElement("button"); - trigger.dataset.uuid = "cap-1"; - trigger.className = "open-details"; + describe("openDetailsFromTrigger", () => { + test("fetches details and calls afterInject on success", async () => { + const modal = document.createElement("div") + const bodyEl = document.createElement("div") + const titleEl = document.createElement("h5") + const trigger = document.createElement("button") + trigger.dataset.uuid = "cap-1" + trigger.className = "open-details" - const afterInject = jest.fn(); - const show = jest.fn(); - window.ModalManager = { - getOrCreateBootstrapModal: () => ({ show }), - }; - window.DetailsModalAssetRegistry = { - capture: { - assetType: "capture", - delegateClickSelectors: [".open-details"], - resolveUuidFromTrigger: (t) => t.dataset.uuid, - resolveShell: () => ({ modal, bodyEl, titleEl }), - buildDetailsUrl: (uuid) => `/details/${uuid}/`, - afterInject, - }, - }; + const afterInject = jest.fn() + const show = jest.fn() + window.ModalManager = { + getOrCreateBootstrapModal: () => ({ show }), + } + window.DetailsModalAssetRegistry = { + capture: { + assetType: "capture", + delegateClickSelectors: [".open-details"], + resolveUuidFromTrigger: (t) => t.dataset.uuid, + resolveShell: () => ({ modal, bodyEl, titleEl }), + buildDetailsUrl: (uuid) => `/details/${uuid}/`, + afterInject, + }, + } - global.fetch = jest - .fn() - .mockResolvedValue( - createMockFetchResponse({ - jsonData: { - title: "Capture A", - html: "

body

", - meta: { uuid: "cap-1" }, - }, - }), - ); + global.fetch = jest.fn().mockResolvedValue( + createMockFetchResponse({ + jsonData: { + title: "Capture A", + html: "

body

", + meta: { uuid: "cap-1" }, + }, + }), + ) - await AssetDetailsModalLoader.openDetailsFromTrigger(trigger); + await AssetDetailsModalLoader.openDetailsFromTrigger(trigger) - expect(fetch).toHaveBeenCalledWith( - "/details/cap-1/", - expect.objectContaining({ - credentials: "same-origin", - headers: { Accept: "application/json" }, - }), - ); - expect(titleEl.textContent).toBe("Capture A"); - expect(bodyEl.innerHTML).toBe("

body

"); - expect(show).toHaveBeenCalled(); - expect(afterInject).toHaveBeenCalledWith( - expect.objectContaining({ uuid: "cap-1" }), - ); - }); + expect(fetch).toHaveBeenCalledWith( + "/details/cap-1/", + expect.objectContaining({ + credentials: "same-origin", + headers: { Accept: "application/json" }, + }), + ) + expect(titleEl.textContent).toBe("Capture A") + expect(bodyEl.innerHTML).toBe("

body

") + expect(show).toHaveBeenCalled() + expect(afterInject).toHaveBeenCalledWith( + expect.objectContaining({ uuid: "cap-1" }), + ) + }) - test("shows error body when fetch fails", async () => { - const modal = document.createElement("div"); - const bodyEl = document.createElement("div"); - const titleEl = document.createElement("h5"); - const trigger = document.createElement("button"); - trigger.dataset.uuid = "ds-1"; - trigger.className = "open-details"; + test("shows error body when fetch fails", async () => { + const modal = document.createElement("div") + const bodyEl = document.createElement("div") + const titleEl = document.createElement("h5") + const trigger = document.createElement("button") + trigger.dataset.uuid = "ds-1" + trigger.className = "open-details" - window.ModalManager = { - getOrCreateBootstrapModal: () => ({ show: jest.fn() }), - }; - window.DetailsModalAssetRegistry = { - dataset: { - assetType: "dataset", - delegateClickSelectors: [".open-details"], - resolveUuidFromTrigger: (t) => t.dataset.uuid, - resolveShell: () => ({ modal, bodyEl, titleEl }), - buildDetailsUrl: () => "/details/", - }, - }; + window.ModalManager = { + getOrCreateBootstrapModal: () => ({ show: jest.fn() }), + } + window.DetailsModalAssetRegistry = { + dataset: { + assetType: "dataset", + delegateClickSelectors: [".open-details"], + resolveUuidFromTrigger: (t) => t.dataset.uuid, + resolveShell: () => ({ modal, bodyEl, titleEl }), + buildDetailsUrl: () => "/details/", + }, + } - global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 500 }); + global.fetch = jest + .fn() + .mockResolvedValue({ ok: false, status: 500 }) - await AssetDetailsModalLoader.openDetailsFromTrigger(trigger); + await AssetDetailsModalLoader.openDetailsFromTrigger(trigger) - expect(bodyEl.innerHTML).toContain("dataset details"); - expect(titleEl.textContent).toBe("Error"); - }); - }); + expect(bodyEl.innerHTML).toContain("dataset details") + expect(titleEl.textContent).toBe("Error") + }) + }) - describe("ensureDetailsClickDelegation", () => { - test("wires click once and cleanup removes listener", () => { - document.body.innerHTML = "
"; - const cleanup = AssetDetailsModalLoader.ensureDetailsClickDelegation(); - expect(document.body.dataset.detailsAssetClickWired).toBe("1"); + describe("ensureDetailsClickDelegation", () => { + test("wires click once and cleanup removes listener", () => { + document.body.innerHTML = "
" + const cleanup = + AssetDetailsModalLoader.ensureDetailsClickDelegation() + expect(document.body.dataset.detailsAssetClickWired).toBe("1") - const cleanup2 = AssetDetailsModalLoader.ensureDetailsClickDelegation(); - expect(cleanup2).toEqual(expect.any(Function)); + const cleanup2 = + AssetDetailsModalLoader.ensureDetailsClickDelegation() + expect(cleanup2).toEqual(expect.any(Function)) - cleanup(); - expect(document.body.dataset.detailsAssetClickWired).toBe(""); - }); + cleanup() + expect(document.body.dataset.detailsAssetClickWired).toBe("") + }) - test("ignores clicks inside dropdown toggles", () => { - document.body.innerHTML = ` + test("ignores clicks inside dropdown toggles", () => { + document.body.innerHTML = ` - `; - const openSpy = jest - .spyOn(AssetDetailsModalLoader, "openDetailsFromTrigger") - .mockResolvedValue(undefined); - window.DetailsModalAssetRegistry = { - capture: { - delegateClickSelectors: [".open-details"], - resolveUuidFromTrigger: () => "x", - }, - }; - const cleanup = - AssetDetailsModalLoader.attachDocumentDetailsClickDelegation(); - document.querySelector(".open-details").click(); - expect(openSpy).not.toHaveBeenCalled(); - cleanup(); - openSpy.mockRestore(); - }); + ` + const openSpy = jest + .spyOn(AssetDetailsModalLoader, "openDetailsFromTrigger") + .mockResolvedValue(undefined) + window.DetailsModalAssetRegistry = { + capture: { + delegateClickSelectors: [".open-details"], + resolveUuidFromTrigger: () => "x", + }, + } + const cleanup = + AssetDetailsModalLoader.attachDocumentDetailsClickDelegation() + document.querySelector(".open-details").click() + expect(openSpy).not.toHaveBeenCalled() + cleanup() + openSpy.mockRestore() + }) - test("delegated capture click prevents default and opens details", async () => { - const link = document.createElement("a"); - link.className = "capture-link open-details"; - link.setAttribute("data-item-uuid", "cap-delegated"); - link.textContent = "Link"; - document.body.appendChild(link); - const openSpy = jest - .spyOn(AssetDetailsModalLoader, "openDetailsFromTrigger") - .mockResolvedValue(undefined); - window.DetailsModalAssetRegistry = { - capture: { - delegateClickSelectors: [".capture-link", ".open-details"], - resolveUuidFromTrigger: (el) => el.getAttribute("data-item-uuid"), - }, - }; - const cleanup = - AssetDetailsModalLoader.attachDocumentDetailsClickDelegation(); - const event = new MouseEvent("click", { - bubbles: true, - cancelable: true, - }); - link.dispatchEvent(event); - expect(event.defaultPrevented).toBe(true); - expect(openSpy).toHaveBeenCalledWith(link); - cleanup(); - openSpy.mockRestore(); - }); - }); + test("delegated capture click prevents default and opens details", async () => { + const link = document.createElement("a") + link.className = "capture-link open-details" + link.setAttribute("data-item-uuid", "cap-delegated") + link.textContent = "Link" + document.body.appendChild(link) + const openSpy = jest + .spyOn(AssetDetailsModalLoader, "openDetailsFromTrigger") + .mockResolvedValue(undefined) + window.DetailsModalAssetRegistry = { + capture: { + delegateClickSelectors: [".capture-link", ".open-details"], + resolveUuidFromTrigger: (el) => + el.getAttribute("data-item-uuid"), + }, + } + const cleanup = + AssetDetailsModalLoader.attachDocumentDetailsClickDelegation() + const event = new MouseEvent("click", { + bubbles: true, + cancelable: true, + }) + link.dispatchEvent(event) + expect(event.defaultPrevented).toBe(true) + expect(openSpy).toHaveBeenCalledWith(link) + cleanup() + openSpy.mockRestore() + }) + }) - describe("openDetailsFromTrigger edge cases", () => { - test("skips fetch when uuid is invalid", async () => { - global.fetch = jest.fn(); - const trigger = document.createElement("button"); - trigger.className = "open-details"; - trigger.dataset.uuid = "null"; - window.DetailsModalAssetRegistry = { - capture: { - assetType: "capture", - delegateClickSelectors: [".open-details"], - resolveUuidFromTrigger: (t) => t.dataset.uuid, - resolveShell: () => ({ - modal: document.createElement("div"), - bodyEl: document.createElement("div"), - titleEl: document.createElement("h5"), - }), - buildDetailsUrl: () => "/x/", - }, - }; - await AssetDetailsModalLoader.openDetailsFromTrigger(trigger); - expect(fetch).not.toHaveBeenCalled(); - }); + describe("openDetailsFromTrigger edge cases", () => { + test("skips fetch when uuid is invalid", async () => { + global.fetch = jest.fn() + const trigger = document.createElement("button") + trigger.className = "open-details" + trigger.dataset.uuid = "null" + window.DetailsModalAssetRegistry = { + capture: { + assetType: "capture", + delegateClickSelectors: [".open-details"], + resolveUuidFromTrigger: (t) => t.dataset.uuid, + resolveShell: () => ({ + modal: document.createElement("div"), + bodyEl: document.createElement("div"), + titleEl: document.createElement("h5"), + }), + buildDetailsUrl: () => "/x/", + }, + } + await AssetDetailsModalLoader.openDetailsFromTrigger(trigger) + expect(fetch).not.toHaveBeenCalled() + }) - test("skips fetch when shell is missing", async () => { - global.fetch = jest.fn(); - const trigger = document.createElement("button"); - trigger.className = "open-details"; - window.DetailsModalAssetRegistry = { - capture: { - assetType: "capture", - delegateClickSelectors: [".open-details"], - resolveUuidFromTrigger: () => "valid-uuid", - resolveShell: () => null, - buildDetailsUrl: () => "/x/", - }, - }; - await AssetDetailsModalLoader.openDetailsFromTrigger(trigger); - expect(fetch).not.toHaveBeenCalled(); - }); + test("skips fetch when shell is missing", async () => { + global.fetch = jest.fn() + const trigger = document.createElement("button") + trigger.className = "open-details" + window.DetailsModalAssetRegistry = { + capture: { + assetType: "capture", + delegateClickSelectors: [".open-details"], + resolveUuidFromTrigger: () => "valid-uuid", + resolveShell: () => null, + buildDetailsUrl: () => "/x/", + }, + } + await AssetDetailsModalLoader.openDetailsFromTrigger(trigger) + expect(fetch).not.toHaveBeenCalled() + }) - test("hides visualize button while loading capture", async () => { - document.body.innerHTML = ''; - const visualizeBtn = document.getElementById("visualize-btn"); - const modal = document.createElement("div"); - const bodyEl = document.createElement("div"); - const titleEl = document.createElement("h5"); - const trigger = document.createElement("button"); - trigger.className = "open-details"; - window.ModalManager = { - getOrCreateBootstrapModal: () => ({ show: jest.fn() }), - }; - window.DetailsModalAssetRegistry = { - capture: { - assetType: "capture", - delegateClickSelectors: [".open-details"], - resolveUuidFromTrigger: () => "cap-1", - resolveShell: () => ({ modal, bodyEl, titleEl }), - buildDetailsUrl: () => "/details/", - }, - }; - let resolveFetch; - global.fetch = jest.fn( - () => - new Promise((resolve) => { - resolveFetch = () => - resolve({ - ok: true, - json: () => - Promise.resolve({ title: "T", html: "", meta: {} }), - }); - }), - ); - const pending = - AssetDetailsModalLoader.openDetailsFromTrigger(trigger); - expect(visualizeBtn.classList.contains("d-none")).toBe(true); - resolveFetch(); - await pending; - }); - }); -}); + test("hides visualize button while loading capture", async () => { + document.body.innerHTML = '' + const visualizeBtn = document.getElementById("visualize-btn") + const modal = document.createElement("div") + const bodyEl = document.createElement("div") + const titleEl = document.createElement("h5") + const trigger = document.createElement("button") + trigger.className = "open-details" + window.ModalManager = { + getOrCreateBootstrapModal: () => ({ show: jest.fn() }), + } + window.DetailsModalAssetRegistry = { + capture: { + assetType: "capture", + delegateClickSelectors: [".open-details"], + resolveUuidFromTrigger: () => "cap-1", + resolveShell: () => ({ modal, bodyEl, titleEl }), + buildDetailsUrl: () => "/details/", + }, + } + let resolveFetch + global.fetch = jest.fn( + () => + new Promise((resolve) => { + resolveFetch = () => + resolve({ + ok: true, + json: () => + Promise.resolve({ + title: "T", + html: "", + meta: {}, + }), + }) + }), + ) + const pending = + AssetDetailsModalLoader.openDetailsFromTrigger(trigger) + expect(visualizeBtn.classList.contains("d-none")).toBe(true) + resolveFetch() + await pending + }) + }) +}) diff --git a/gateway/sds_gateway/static/js/actions/details/__tests__/CaptureDetailsModalBehavior.test.js b/gateway/sds_gateway/static/js/actions/details/__tests__/CaptureDetailsModalBehavior.test.js index 8c0a2c5a3..76a402533 100644 --- a/gateway/sds_gateway/static/js/actions/details/__tests__/CaptureDetailsModalBehavior.test.js +++ b/gateway/sds_gateway/static/js/actions/details/__tests__/CaptureDetailsModalBehavior.test.js @@ -2,103 +2,108 @@ * Jest tests for CaptureDetailsModalBehavior (capture name edit propagation) */ -import { CaptureDetailsModalBehavior } from "../CaptureDetailsModalBehavior.js"; -const { createMockDOMUtils } = require("../../../tests-config/testHelpers.js"); +import { CaptureDetailsModalBehavior } from "../CaptureDetailsModalBehavior.js" +const { createMockDOMUtils } = require("../../../tests-config/testHelpers.js") describe("CaptureDetailsModalBehavior", () => { - beforeEach(() => { - jest.clearAllMocks(); - document.body.innerHTML = ""; - window.DOMUtils = createMockDOMUtils(); - global.APIClient = jest.fn().mockImplementation(() => ({ - getCSRFToken: () => "csrf-test", - })); - global.fetch = jest.fn(); - }); - - test("updateCaptureName PATCHes capture with CSRF", async () => { - global.fetch.mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ name: "Renamed" }), - }); - - const result = await CaptureDetailsModalBehavior.updateCaptureName( - "cap-uuid-1", - "Renamed", - ); - - expect(global.fetch).toHaveBeenCalledWith( - "/api/v1/assets/captures/cap-uuid-1/", - expect.objectContaining({ - method: "PATCH", - headers: expect.objectContaining({ - "X-CSRFToken": "csrf-test", - }), - body: JSON.stringify({ name: "Renamed" }), - }), - ); - expect(result.name).toBe("Renamed"); - }); - - test("updateTableNameDisplay updates capture-link row text", () => { - document.body.innerHTML = ` + beforeEach(() => { + jest.clearAllMocks() + document.body.innerHTML = "" + window.DOMUtils = createMockDOMUtils() + global.APIClient = jest.fn().mockImplementation(() => ({ + getCSRFToken: () => "csrf-test", + })) + global.fetch = jest.fn() + }) + + test("updateCaptureName PATCHes capture with CSRF", async () => { + global.fetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ name: "Renamed" }), + }) + + const result = await CaptureDetailsModalBehavior.updateCaptureName( + "cap-uuid-1", + "Renamed", + ) + + expect(global.fetch).toHaveBeenCalledWith( + "/api/v1/assets/captures/cap-uuid-1/", + expect.objectContaining({ + method: "PATCH", + headers: expect.objectContaining({ + "X-CSRFToken": "csrf-test", + }), + body: JSON.stringify({ name: "Renamed" }), + }), + ) + expect(result.name).toBe("Renamed") + }) + + test("updateTableNameDisplay updates capture-link row text", () => { + document.body.innerHTML = ` Old - `; - - CaptureDetailsModalBehavior.updateTableNameDisplay("cap-uuid-1", "New Name"); - - const link = document.querySelector(".capture-link[data-uuid='cap-uuid-1']"); - expect(link.textContent).toBe("New Name"); - expect(link.dataset.name).toBe("New Name"); - expect(link.getAttribute("aria-label")).toContain("New Name"); - }); - - test("setupVisualizeFromMeta wires visualize button when enabled", () => { - document.body.innerHTML = - ''; - const openWithCaptureData = jest.fn(); - window.visualizationModalInstance = { openWithCaptureData }; - - CaptureDetailsModalBehavior.setupVisualizeFromMeta({ - visualize_enabled: true, - uuid: "cap-2", - capture_type: "drf", - }); - - const btn = document.getElementById("visualize-btn"); - expect(btn.classList.contains("d-none")).toBe(false); - expect(btn.dataset.captureUuid).toBe("cap-2"); - btn.onclick(); - expect(openWithCaptureData).toHaveBeenCalledWith("cap-2", "drf"); - }); - - test("afterInject enables visualize button when meta allows DRF", () => { - document.body.innerHTML = ` + ` + + CaptureDetailsModalBehavior.updateTableNameDisplay( + "cap-uuid-1", + "New Name", + ) + + const link = document.querySelector( + ".capture-link[data-uuid='cap-uuid-1']", + ) + expect(link.textContent).toBe("New Name") + expect(link.dataset.name).toBe("New Name") + expect(link.getAttribute("aria-label")).toContain("New Name") + }) + + test("setupVisualizeFromMeta wires visualize button when enabled", () => { + document.body.innerHTML = + '' + const openWithCaptureData = jest.fn() + window.visualizationModalInstance = { openWithCaptureData } + + CaptureDetailsModalBehavior.setupVisualizeFromMeta({ + visualize_enabled: true, + uuid: "cap-2", + capture_type: "drf", + }) + + const btn = document.getElementById("visualize-btn") + expect(btn.classList.contains("d-none")).toBe(false) + expect(btn.dataset.captureUuid).toBe("cap-2") + btn.onclick() + expect(openWithCaptureData).toHaveBeenCalledWith("cap-2", "drf") + }) + + test("afterInject enables visualize button when meta allows DRF", () => { + document.body.innerHTML = `
- `; - window.visualizationModalInstance = { - openWithCaptureData: jest.fn(), - }; - - CaptureDetailsModalBehavior.afterInject({ - modal: document.getElementById("asset-details-modal"), - meta: { visualize_enabled: true, uuid: "c1", capture_type: "drf" }, - }); - - const btn = document.getElementById("visualize-btn"); - expect(btn.classList.contains("d-none")).toBe(false); - expect(btn.dataset.captureUuid).toBe("c1"); - }); - - test("updateCaptureName throws when response not ok", async () => { - global.fetch.mockResolvedValue({ - ok: false, - json: () => Promise.resolve({ detail: "Forbidden" }), - }); - - await expect( - CaptureDetailsModalBehavior.updateCaptureName("cap-uuid-1", "X"), - ).rejects.toThrow("Forbidden"); - }); -}); + ` + window.visualizationModalInstance = { + openWithCaptureData: jest.fn(), + } + + CaptureDetailsModalBehavior.afterInject({ + modal: document.getElementById("asset-details-modal"), + meta: { visualize_enabled: true, uuid: "c1", capture_type: "drf" }, + }) + + const btn = document.getElementById("visualize-btn") + expect(btn.classList.contains("d-none")).toBe(false) + expect(btn.dataset.captureUuid).toBe("c1") + }) + + test("updateCaptureName throws when response not ok", async () => { + global.fetch.mockResolvedValue({ + ok: false, + json: () => Promise.resolve({ detail: "Forbidden" }), + }) + + await expect( + CaptureDetailsModalBehavior.updateCaptureName("cap-uuid-1", "X"), + ).rejects.toThrow("Forbidden") + }) +}) diff --git a/gateway/sds_gateway/static/js/actions/download/captureDownloadSlider.js b/gateway/sds_gateway/static/js/actions/download/captureDownloadSlider.js index 7bb316fb8..faf39ffe3 100644 --- a/gateway/sds_gateway/static/js/actions/download/captureDownloadSlider.js +++ b/gateway/sds_gateway/static/js/actions/download/captureDownloadSlider.js @@ -1,334 +1,356 @@ /** Capture download temporal slider (extracted from DownloadActionManager). */ function msToHms(ms) { - const n = Number(ms); - if (!Number.isFinite(n) || n < 0) return "0:00:00.000"; - const totalSec = Math.floor(n / 1000); - const h = Math.floor(totalSec / 3600); - const m = Math.floor((totalSec % 3600) / 60); - const s = totalSec % 60; - const decimalMs = n % 1000; - const hms = [h, m, s].map((v) => String(v).padStart(2, "0")).join(":"); - return `${hms}.${String(decimalMs).padStart(3, "0")}`; + const n = Number(ms) + if (!Number.isFinite(n) || n < 0) return "0:00:00.000" + const totalSec = Math.floor(n / 1000) + const h = Math.floor(totalSec / 3600) + const m = Math.floor((totalSec % 3600) / 60) + const s = totalSec % 60 + const decimalMs = n % 1000 + const hms = [h, m, s].map((v) => String(v).padStart(2, "0")).join(":") + return `${hms}.${String(decimalMs).padStart(3, "0")}` } function formatUtcRange(startEpochSec, startMs, endMs) { - if (!Number.isFinite(startEpochSec)) return "—"; - const startDate = new Date(startEpochSec * 1000 + startMs); - const endDate = new Date(startEpochSec * 1000 + endMs); - const pad2 = (x) => String(x).padStart(2, "0"); - const fmt = (d) => - `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())} ${pad2(d.getUTCMonth() + 1)}/${pad2(d.getUTCDate())}/${d.getUTCFullYear()}`; - return `${fmt(startDate)} - ${fmt(endDate)} (UTC)`; + if (!Number.isFinite(startEpochSec)) return "—" + const startDate = new Date(startEpochSec * 1000 + startMs) + const endDate = new Date(startEpochSec * 1000 + endMs) + const pad2 = (x) => String(x).padStart(2, "0") + const fmt = (d) => + `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())} ${pad2(d.getUTCMonth() + 1)}/${pad2(d.getUTCDate())}/${d.getUTCFullYear()}` + return `${fmt(startDate)} - ${fmt(endDate)} (UTC)` } /** Format ms from capture start as UTC string for display (Y-m-d H:i:s). */ function msToUtcString(captureStartEpochSec, ms) { - if (!Number.isFinite(captureStartEpochSec) || !Number.isFinite(ms)) return ""; - const d = new Date(captureStartEpochSec * 1000 + ms); - const pad2 = (x) => String(x).padStart(2, "0"); - return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`; + if (!Number.isFinite(captureStartEpochSec) || !Number.isFinite(ms)) + return "" + const d = new Date(captureStartEpochSec * 1000 + ms) + const pad2 = (x) => String(x).padStart(2, "0") + return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}` } /** Parse UTC date string (Y-m-d H:i:s or Y-m-d H:i) to epoch ms. */ function parseUtcStringToEpochMs(str) { - if (!str || !str.trim()) return Number.NaN; - const s = str.trim(); - const d = new Date(s.endsWith("Z") ? s : `${s.replace(" ", "T")}Z`); - return Number.isFinite(d.getTime()) ? d.getTime() : Number.NaN; + if (!str || !str.trim()) return Number.NaN + const s = str.trim() + const d = new Date(s.endsWith("Z") ? s : `${s.replace(" ", "T")}Z`) + return Number.isFinite(d.getTime()) ? d.getTime() : Number.NaN } -function initializeCaptureDownloadSlider(modalId, durationMs, fileCadenceMs, opts) { - const webDownloadModal = document.getElementById(modalId); - if (!webDownloadModal) return; +function initializeCaptureDownloadSlider( + modalId, + durationMs, + fileCadenceMs, + opts, +) { + const webDownloadModal = document.getElementById(modalId) + if (!webDownloadModal) return - const resolvedOpts = opts ?? {}; - const q = (id) => webDownloadModal.querySelector(`#${id}`); - const sliderEl = q("temporalFilterSlider"); - const rangeLabel = q("temporalFilterRangeLabel"); - const totalFilesLabel = q("totalFilesLabel"); - const metadataFilesLabel = q("metadataFilesLabel"); - const totalSizeLabel = q("totalSizeLabel"); - const dateTimeLabel = q("dateTimeLabel"); - const startTimeInput = q("startTime"); - const endTimeInput = q("endTime"); - const startTimeEntry = q("startTimeEntry"); - const endTimeEntry = q("endTimeEntry"); - const startDateTimeEntry = q("startDateTimeEntry"); - const endDateTimeEntry = q("endDateTimeEntry"); - const rangeHintEl = q("temporalRangeHint"); - const sizeWarningEl = q("temporalFilterSizeWarning"); - if (!sliderEl || typeof noUiSlider === "undefined") return; - const resolvedDurationMs = (() => { - const n = Number(durationMs); - return !Number.isFinite(n) || n < 0 ? 0 : n; - })(); - const resolvedFileCadenceMs = (() => { - const n = Number(fileCadenceMs); - return !Number.isFinite(n) || n < 1 ? 1000 : n; - })(); - const perDataFileSize = Number(resolvedOpts.perDataFileSize) || 0; - const totalSize = Number(resolvedOpts.totalSize) || 0; - const dataFilesCount = Number(resolvedOpts.dataFilesCount) || 0; - const totalFilesCount = Number(resolvedOpts.totalFilesCount) || 0; - let dataFilesTotalSize = Number(resolvedOpts.dataFilesTotalSize); - if (!Number.isFinite(dataFilesTotalSize) || dataFilesTotalSize < 0) { - dataFilesTotalSize = perDataFileSize * dataFilesCount; - } - let metadataFilesTotalSize = totalSize - dataFilesTotalSize; - if (metadataFilesTotalSize < 0) metadataFilesTotalSize = 0; - const metadataFilesCount = Math.max(0, totalFilesCount - dataFilesCount); - const captureUuid = - resolvedOpts.captureUuid != null ? String(resolvedOpts.captureUuid) : ""; - const captureStartEpochSec = Number(resolvedOpts.captureStartEpochSec); - if (totalSize > 0 && dataFilesTotalSize > totalSize) { - console.warn( - "[DownloadActionManager] data files total size exceeds total size (backend/query inconsistency).", - { - captureUuid: captureUuid || "(unknown)", - totalSize, - dataFilesTotalSize, - perDataFileSize, - dataFilesCount, - }, - ); - if (sizeWarningEl) { - sizeWarningEl.classList.remove("d-none"); - } - dataFilesTotalSize = totalSize; - metadataFilesTotalSize = 0; - } else if (sizeWarningEl) { - sizeWarningEl.classList.add("d-none"); - } - if (webDownloadModal) { - webDownloadModal.dataset.durationMs = String( - Math.round(resolvedDurationMs), - ); - webDownloadModal.dataset.fileCadenceMs = String(resolvedFileCadenceMs); - webDownloadModal.dataset.captureStartEpochSec = Number.isFinite( - captureStartEpochSec, - ) - ? String(captureStartEpochSec) - : ""; - } - if (rangeHintEl) - rangeHintEl.textContent = `0 – ${Math.round(resolvedDurationMs)} ms`; - if (sliderEl.noUiSlider) { - sliderEl.noUiSlider.destroy(); - } - if (rangeLabel) rangeLabel.textContent = "—"; - if (totalFilesLabel) totalFilesLabel.textContent = "0 files"; - if (totalSizeLabel) - totalSizeLabel.textContent = window.DOMUtils.formatFileSize(totalSize); - if (dateTimeLabel) dateTimeLabel.textContent = "—"; - if (startTimeInput) startTimeInput.value = ""; - if (endTimeInput) endTimeInput.value = ""; - if (startTimeEntry) startTimeEntry.value = ""; - if (endTimeEntry) endTimeEntry.value = ""; - const hasEpoch = Number.isFinite(captureStartEpochSec); - if (startDateTimeEntry) { - startDateTimeEntry.value = ""; - startDateTimeEntry.disabled = !hasEpoch; - } - if (endDateTimeEntry) { - endDateTimeEntry.value = ""; - endDateTimeEntry.disabled = !hasEpoch; - } - if (resolvedDurationMs <= 0) return; - let fpStart = null; - let fpEnd = null; - const epochStart = captureStartEpochSec * 1000; - const epochEnd = epochStart + resolvedDurationMs; - if ( - hasEpoch && - typeof flatpickr !== "undefined" && - startDateTimeEntry && - endDateTimeEntry - ) { - const fpOpts = { - enableTime: true, - enableSeconds: true, - utc: true, - dateFormat: "Y-m-d H:i:S", - time_24hr: true, - minDate: epochStart, - maxDate: epochEnd, - allowInput: true, - static: true, - appendTo: webDownloadModal || undefined, - }; - flatpickr( - startDateTimeEntry, - Object.assign({}, fpOpts, { - onChange: () => { - syncFromDateTimeEntries(); - }, - }), - ); - flatpickr( - endDateTimeEntry, - Object.assign({}, fpOpts, { - onChange: () => { - syncFromDateTimeEntries(); - }, - }), - ); - fpStart = startDateTimeEntry._flatpickr; - fpEnd = endDateTimeEntry._flatpickr; - startDateTimeEntry.disabled = false; - endDateTimeEntry.disabled = false; - } - noUiSlider.create(sliderEl, { - start: [0, resolvedDurationMs], - connect: true, - step: resolvedFileCadenceMs, - range: { min: 0, max: resolvedDurationMs }, - }); - sliderEl.noUiSlider.on("update", (values) => { - const startMs = Number(values[0]); - const endMs = Number(values[1]); - // the + 1 is to include the first file in the selection - // as file cadence is the time between files, not the time of the file - const filesInSelection = - Math.round((endMs - startMs) / resolvedFileCadenceMs) + 1; - if (rangeLabel) { - rangeLabel.textContent = `${msToHms(startMs)} - ${msToHms(endMs)}`; - } - if (totalFilesLabel) { - totalFilesLabel.textContent = - dataFilesCount > 0 - ? `${filesInSelection} of ${dataFilesCount} files` - : `${filesInSelection} files`; - } - if (totalSizeLabel) { - totalSizeLabel.textContent = window.DOMUtils.formatFileSize( - perDataFileSize * filesInSelection + metadataFilesTotalSize, - ); - } - if (dateTimeLabel && Number.isFinite(captureStartEpochSec)) { - dateTimeLabel.textContent = formatUtcRange( - captureStartEpochSec, - startMs, - endMs, - ); - } - if (startTimeInput) startTimeInput.value = String(Math.round(startMs)); - if (endTimeInput) endTimeInput.value = String(Math.round(endMs)); - if (startTimeEntry) startTimeEntry.value = String(Math.round(startMs)); - if (endTimeEntry) endTimeEntry.value = String(Math.round(endMs)); - if (hasEpoch) { - if (fpStart && typeof fpStart.setDate === "function") - fpStart.setDate(epochStart + startMs); - else if (startDateTimeEntry) - startDateTimeEntry.value = msToUtcString( - captureStartEpochSec, - startMs, - ); - if (fpEnd && typeof fpEnd.setDate === "function") - fpEnd.setDate(epochStart + endMs); - else if (endDateTimeEntry) - endDateTimeEntry.value = msToUtcString(captureStartEpochSec, endMs); - } - }); - if (rangeLabel) { - rangeLabel.textContent = `0:00:00.000 - ${msToHms(resolvedDurationMs)}`; - } - if (totalFilesLabel) { - totalFilesLabel.textContent = - dataFilesCount > 0 ? `${dataFilesCount} files` : "0 files"; - } - if (metadataFilesLabel) { - metadataFilesLabel.textContent = - metadataFilesCount > 0 ? `${metadataFilesCount} files` : "0 files"; - } - if (dateTimeLabel && Number.isFinite(captureStartEpochSec)) { - dateTimeLabel.textContent = formatUtcRange( - captureStartEpochSec, - 0, - resolvedDurationMs, - ); - } - const startVal = "0"; - const endVal = String(resolvedDurationMs); - if (startTimeInput) startTimeInput.value = startVal; - if (endTimeInput) endTimeInput.value = endVal; - if (startTimeEntry) startTimeEntry.value = startVal; - if (endTimeEntry) endTimeEntry.value = endVal; - if (hasEpoch && startDateTimeEntry && endDateTimeEntry) { - if (fpStart && typeof fpStart.setDate === "function") - fpStart.setDate(epochStart); - else startDateTimeEntry.value = msToUtcString(captureStartEpochSec, 0); - if (fpEnd && typeof fpEnd.setDate === "function") fpEnd.setDate(epochEnd); - else - endDateTimeEntry.value = msToUtcString( - captureStartEpochSec, - resolvedDurationMs, - ); - if (!fpStart) { - startDateTimeEntry.disabled = false; - endDateTimeEntry.disabled = false; - } - } + const resolvedOpts = opts ?? {} + const q = (id) => webDownloadModal.querySelector(`#${id}`) + const sliderEl = q("temporalFilterSlider") + const rangeLabel = q("temporalFilterRangeLabel") + const totalFilesLabel = q("totalFilesLabel") + const metadataFilesLabel = q("metadataFilesLabel") + const totalSizeLabel = q("totalSizeLabel") + const dateTimeLabel = q("dateTimeLabel") + const startTimeInput = q("startTime") + const endTimeInput = q("endTime") + const startTimeEntry = q("startTimeEntry") + const endTimeEntry = q("endTimeEntry") + const startDateTimeEntry = q("startDateTimeEntry") + const endDateTimeEntry = q("endDateTimeEntry") + const rangeHintEl = q("temporalRangeHint") + const sizeWarningEl = q("temporalFilterSizeWarning") + if (!sliderEl || typeof noUiSlider === "undefined") return + const resolvedDurationMs = (() => { + const n = Number(durationMs) + return !Number.isFinite(n) || n < 0 ? 0 : n + })() + const resolvedFileCadenceMs = (() => { + const n = Number(fileCadenceMs) + return !Number.isFinite(n) || n < 1 ? 1000 : n + })() + const perDataFileSize = Number(resolvedOpts.perDataFileSize) || 0 + const totalSize = Number(resolvedOpts.totalSize) || 0 + const dataFilesCount = Number(resolvedOpts.dataFilesCount) || 0 + const totalFilesCount = Number(resolvedOpts.totalFilesCount) || 0 + let dataFilesTotalSize = Number(resolvedOpts.dataFilesTotalSize) + if (!Number.isFinite(dataFilesTotalSize) || dataFilesTotalSize < 0) { + dataFilesTotalSize = perDataFileSize * dataFilesCount + } + let metadataFilesTotalSize = totalSize - dataFilesTotalSize + if (metadataFilesTotalSize < 0) metadataFilesTotalSize = 0 + const metadataFilesCount = Math.max(0, totalFilesCount - dataFilesCount) + const captureUuid = + resolvedOpts.captureUuid != null ? String(resolvedOpts.captureUuid) : "" + const captureStartEpochSec = Number(resolvedOpts.captureStartEpochSec) + if (totalSize > 0 && dataFilesTotalSize > totalSize) { + console.warn( + "[DownloadActionManager] data files total size exceeds total size (backend/query inconsistency).", + { + captureUuid: captureUuid || "(unknown)", + totalSize, + dataFilesTotalSize, + perDataFileSize, + dataFilesCount, + }, + ) + if (sizeWarningEl) { + sizeWarningEl.classList.remove("d-none") + } + dataFilesTotalSize = totalSize + metadataFilesTotalSize = 0 + } else if (sizeWarningEl) { + sizeWarningEl.classList.add("d-none") + } + if (webDownloadModal) { + webDownloadModal.dataset.durationMs = String( + Math.round(resolvedDurationMs), + ) + webDownloadModal.dataset.fileCadenceMs = String(resolvedFileCadenceMs) + webDownloadModal.dataset.captureStartEpochSec = Number.isFinite( + captureStartEpochSec, + ) + ? String(captureStartEpochSec) + : "" + } + if (rangeHintEl) + rangeHintEl.textContent = `0 – ${Math.round(resolvedDurationMs)} ms` + if (sliderEl.noUiSlider) { + sliderEl.noUiSlider.destroy() + } + if (rangeLabel) rangeLabel.textContent = "—" + if (totalFilesLabel) totalFilesLabel.textContent = "0 files" + if (totalSizeLabel) + totalSizeLabel.textContent = window.DOMUtils.formatFileSize(totalSize) + if (dateTimeLabel) dateTimeLabel.textContent = "—" + if (startTimeInput) startTimeInput.value = "" + if (endTimeInput) endTimeInput.value = "" + if (startTimeEntry) startTimeEntry.value = "" + if (endTimeEntry) endTimeEntry.value = "" + const hasEpoch = Number.isFinite(captureStartEpochSec) + if (startDateTimeEntry) { + startDateTimeEntry.value = "" + startDateTimeEntry.disabled = !hasEpoch + } + if (endDateTimeEntry) { + endDateTimeEntry.value = "" + endDateTimeEntry.disabled = !hasEpoch + } + if (resolvedDurationMs <= 0) return + let fpStart = null + let fpEnd = null + const epochStart = captureStartEpochSec * 1000 + const epochEnd = epochStart + resolvedDurationMs + if ( + hasEpoch && + typeof flatpickr !== "undefined" && + startDateTimeEntry && + endDateTimeEntry + ) { + const fpOpts = { + enableTime: true, + enableSeconds: true, + utc: true, + dateFormat: "Y-m-d H:i:S", + time_24hr: true, + minDate: epochStart, + maxDate: epochEnd, + allowInput: true, + static: true, + appendTo: webDownloadModal || undefined, + } + flatpickr( + startDateTimeEntry, + Object.assign({}, fpOpts, { + onChange: () => { + syncFromDateTimeEntries() + }, + }), + ) + flatpickr( + endDateTimeEntry, + Object.assign({}, fpOpts, { + onChange: () => { + syncFromDateTimeEntries() + }, + }), + ) + fpStart = startDateTimeEntry._flatpickr + fpEnd = endDateTimeEntry._flatpickr + startDateTimeEntry.disabled = false + endDateTimeEntry.disabled = false + } + noUiSlider.create(sliderEl, { + start: [0, resolvedDurationMs], + connect: true, + step: resolvedFileCadenceMs, + range: { min: 0, max: resolvedDurationMs }, + }) + sliderEl.noUiSlider.on("update", (values) => { + const startMs = Number(values[0]) + const endMs = Number(values[1]) + // the + 1 is to include the first file in the selection + // as file cadence is the time between files, not the time of the file + const filesInSelection = + Math.round((endMs - startMs) / resolvedFileCadenceMs) + 1 + if (rangeLabel) { + rangeLabel.textContent = `${msToHms(startMs)} - ${msToHms(endMs)}` + } + if (totalFilesLabel) { + totalFilesLabel.textContent = + dataFilesCount > 0 + ? `${filesInSelection} of ${dataFilesCount} files` + : `${filesInSelection} files` + } + if (totalSizeLabel) { + totalSizeLabel.textContent = window.DOMUtils.formatFileSize( + perDataFileSize * filesInSelection + metadataFilesTotalSize, + ) + } + if (dateTimeLabel && Number.isFinite(captureStartEpochSec)) { + dateTimeLabel.textContent = formatUtcRange( + captureStartEpochSec, + startMs, + endMs, + ) + } + if (startTimeInput) startTimeInput.value = String(Math.round(startMs)) + if (endTimeInput) endTimeInput.value = String(Math.round(endMs)) + if (startTimeEntry) startTimeEntry.value = String(Math.round(startMs)) + if (endTimeEntry) endTimeEntry.value = String(Math.round(endMs)) + if (hasEpoch) { + if (fpStart && typeof fpStart.setDate === "function") + fpStart.setDate(epochStart + startMs) + else if (startDateTimeEntry) + startDateTimeEntry.value = msToUtcString( + captureStartEpochSec, + startMs, + ) + if (fpEnd && typeof fpEnd.setDate === "function") + fpEnd.setDate(epochStart + endMs) + else if (endDateTimeEntry) + endDateTimeEntry.value = msToUtcString( + captureStartEpochSec, + endMs, + ) + } + }) + if (rangeLabel) { + rangeLabel.textContent = `0:00:00.000 - ${msToHms(resolvedDurationMs)}` + } + if (totalFilesLabel) { + totalFilesLabel.textContent = + dataFilesCount > 0 ? `${dataFilesCount} files` : "0 files" + } + if (metadataFilesLabel) { + metadataFilesLabel.textContent = + metadataFilesCount > 0 ? `${metadataFilesCount} files` : "0 files" + } + if (dateTimeLabel && Number.isFinite(captureStartEpochSec)) { + dateTimeLabel.textContent = formatUtcRange( + captureStartEpochSec, + 0, + resolvedDurationMs, + ) + } + const startVal = "0" + const endVal = String(resolvedDurationMs) + if (startTimeInput) startTimeInput.value = startVal + if (endTimeInput) endTimeInput.value = endVal + if (startTimeEntry) startTimeEntry.value = startVal + if (endTimeEntry) endTimeEntry.value = endVal + if (hasEpoch && startDateTimeEntry && endDateTimeEntry) { + if (fpStart && typeof fpStart.setDate === "function") + fpStart.setDate(epochStart) + else startDateTimeEntry.value = msToUtcString(captureStartEpochSec, 0) + if (fpEnd && typeof fpEnd.setDate === "function") + fpEnd.setDate(epochEnd) + else + endDateTimeEntry.value = msToUtcString( + captureStartEpochSec, + resolvedDurationMs, + ) + if (!fpStart) { + startDateTimeEntry.disabled = false + endDateTimeEntry.disabled = false + } + } - function syncSliderFromEntries() { - if (!sliderEl.noUiSlider || !startTimeEntry || !endTimeEntry) return; - const s = startTimeEntry.value.trim(); - const e = endTimeEntry.value.trim(); - let startMs = s === "" ? 0 : Number.parseInt(s, 10); - let endMs = e === "" ? resolvedDurationMs : Number.parseInt(e, 10); - if (!Number.isFinite(startMs)) startMs = 0; - if (!Number.isFinite(endMs)) endMs = resolvedDurationMs; - startMs = Math.max(0, Math.min(startMs, resolvedDurationMs)); - endMs = Math.max(0, Math.min(endMs, resolvedDurationMs)); - if (startMs >= endMs) - endMs = Math.min(startMs + resolvedFileCadenceMs, resolvedDurationMs); - sliderEl.noUiSlider.set([startMs, endMs]); - } - function syncFromDateTimeEntries() { - if ( - !hasEpoch || - !sliderEl.noUiSlider || - !startDateTimeEntry || - !endDateTimeEntry - ) - return; - let startMs; - let endMs; - if (startDateTimeEntry._flatpickr && endDateTimeEntry._flatpickr) { - const dStart = startDateTimeEntry._flatpickr.selectedDates[0]; - const dEnd = endDateTimeEntry._flatpickr.selectedDates[0]; - startMs = dStart ? dStart.getTime() - epochStart : 0; - endMs = dEnd ? dEnd.getTime() - epochStart : resolvedDurationMs; - } else { - startMs = - parseUtcStringToEpochMs(startDateTimeEntry.value) - epochStart; - endMs = parseUtcStringToEpochMs(endDateTimeEntry.value) - epochStart; - } - if (Number.isNaN(startMs) || Number.isNaN(endMs)) return; - startMs = Math.max(0, Math.min(startMs, resolvedDurationMs)); - endMs = Math.max(0, Math.min(endMs, resolvedDurationMs)); - if (startMs >= endMs) - endMs = Math.min(startMs + resolvedFileCadenceMs, resolvedDurationMs); - const cur = sliderEl.noUiSlider.get(); - if ( - Math.round(Number(cur[0])) === Math.round(startMs) && - Math.round(Number(cur[1])) === Math.round(endMs) - ) - return; - sliderEl.noUiSlider.set([startMs, endMs]); - } - if (startTimeEntry) - startTimeEntry.addEventListener("change", syncSliderFromEntries); - if (endTimeEntry) - endTimeEntry.addEventListener("change", syncSliderFromEntries); - if (startDateTimeEntry && !startDateTimeEntry._flatpickr) - startDateTimeEntry.addEventListener("change", syncFromDateTimeEntries); - if (endDateTimeEntry && !endDateTimeEntry._flatpickr) - endDateTimeEntry.addEventListener("change", syncFromDateTimeEntries); + function syncSliderFromEntries() { + if (!sliderEl.noUiSlider || !startTimeEntry || !endTimeEntry) return + const s = startTimeEntry.value.trim() + const e = endTimeEntry.value.trim() + let startMs = s === "" ? 0 : Number.parseInt(s, 10) + let endMs = e === "" ? resolvedDurationMs : Number.parseInt(e, 10) + if (!Number.isFinite(startMs)) startMs = 0 + if (!Number.isFinite(endMs)) endMs = resolvedDurationMs + startMs = Math.max(0, Math.min(startMs, resolvedDurationMs)) + endMs = Math.max(0, Math.min(endMs, resolvedDurationMs)) + if (startMs >= endMs) + endMs = Math.min( + startMs + resolvedFileCadenceMs, + resolvedDurationMs, + ) + sliderEl.noUiSlider.set([startMs, endMs]) + } + function syncFromDateTimeEntries() { + if ( + !hasEpoch || + !sliderEl.noUiSlider || + !startDateTimeEntry || + !endDateTimeEntry + ) + return + let startMs + let endMs + if (startDateTimeEntry._flatpickr && endDateTimeEntry._flatpickr) { + const dStart = startDateTimeEntry._flatpickr.selectedDates[0] + const dEnd = endDateTimeEntry._flatpickr.selectedDates[0] + startMs = dStart ? dStart.getTime() - epochStart : 0 + endMs = dEnd ? dEnd.getTime() - epochStart : resolvedDurationMs + } else { + startMs = + parseUtcStringToEpochMs(startDateTimeEntry.value) - epochStart + endMs = parseUtcStringToEpochMs(endDateTimeEntry.value) - epochStart + } + if (Number.isNaN(startMs) || Number.isNaN(endMs)) return + startMs = Math.max(0, Math.min(startMs, resolvedDurationMs)) + endMs = Math.max(0, Math.min(endMs, resolvedDurationMs)) + if (startMs >= endMs) + endMs = Math.min( + startMs + resolvedFileCadenceMs, + resolvedDurationMs, + ) + const cur = sliderEl.noUiSlider.get() + if ( + Math.round(Number(cur[0])) === Math.round(startMs) && + Math.round(Number(cur[1])) === Math.round(endMs) + ) + return + sliderEl.noUiSlider.set([startMs, endMs]) + } + if (startTimeEntry) + startTimeEntry.addEventListener("change", syncSliderFromEntries) + if (endTimeEntry) + endTimeEntry.addEventListener("change", syncSliderFromEntries) + if (startDateTimeEntry && !startDateTimeEntry._flatpickr) + startDateTimeEntry.addEventListener("change", syncFromDateTimeEntries) + if (endDateTimeEntry && !endDateTimeEntry._flatpickr) + endDateTimeEntry.addEventListener("change", syncFromDateTimeEntries) } if (typeof window !== "undefined") { - window.initializeCaptureDownloadSlider = initializeCaptureDownloadSlider; + window.initializeCaptureDownloadSlider = initializeCaptureDownloadSlider } if (typeof module !== "undefined" && module.exports) { - module.exports = { initializeCaptureDownloadSlider, msToHms, formatUtcRange, msToUtcString, parseUtcStringToEpochMs }; + module.exports = { + initializeCaptureDownloadSlider, + msToHms, + formatUtcRange, + msToUtcString, + parseUtcStringToEpochMs, + } } diff --git a/gateway/sds_gateway/static/js/actions/quickAdd/quickAddApi.js b/gateway/sds_gateway/static/js/actions/quickAdd/quickAddApi.js index d2ec019d8..1e78608e1 100644 --- a/gateway/sds_gateway/static/js/actions/quickAdd/quickAddApi.js +++ b/gateway/sds_gateway/static/js/actions/quickAdd/quickAddApi.js @@ -9,29 +9,29 @@ * @returns {Promise<{ added: number, skipped: number, errors: string[], success: boolean }>} */ async function postQuickAddCapture(quickAddUrl, datasetUuid, captureUuid) { - const response = await window.APIClient.post( - quickAddUrl, - { - dataset_uuid: datasetUuid, - capture_uuid: captureUuid, - }, - null, - true, - ); - if (!response.success) { - return { - added: 0, - skipped: 0, - errors: [response.error || "Request failed"], - success: false, - }; - } - return { - added: response.added?.length ?? 0, - skipped: response.skipped?.length ?? 0, - errors: [...(response.errors || [])], - success: true, - }; + const response = await window.APIClient.post( + quickAddUrl, + { + dataset_uuid: datasetUuid, + capture_uuid: captureUuid, + }, + null, + true, + ) + if (!response.success) { + return { + added: 0, + skipped: 0, + errors: [response.error || "Request failed"], + success: false, + } + } + return { + added: response.added?.length ?? 0, + skipped: response.skipped?.length ?? 0, + errors: [...(response.errors || [])], + success: true, + } } /** @@ -40,61 +40,61 @@ async function postQuickAddCapture(quickAddUrl, datasetUuid, captureUuid) { * @param {string[]} captureUuids */ async function postQuickAddCaptures(quickAddUrl, datasetUuid, captureUuids) { - let totalAdded = 0; - let totalSkipped = 0; - const errorMessages = []; - for (const captureUuid of captureUuids) { - try { - const result = await postQuickAddCapture( - quickAddUrl, - datasetUuid, - captureUuid, - ); - if (result.success) { - totalAdded += result.added; - totalSkipped += result.skipped; - if (result.errors.length) { - errorMessages.push(...result.errors); - } - } else { - errorMessages.push(...result.errors); - } - } catch (err) { - errorMessages.push(err?.data?.error || err?.message || String(err)); - } - } - return { totalAdded, totalSkipped, errorMessages }; + let totalAdded = 0 + let totalSkipped = 0 + const errorMessages = [] + for (const captureUuid of captureUuids) { + try { + const result = await postQuickAddCapture( + quickAddUrl, + datasetUuid, + captureUuid, + ) + if (result.success) { + totalAdded += result.added + totalSkipped += result.skipped + if (result.errors.length) { + errorMessages.push(...result.errors) + } + } else { + errorMessages.push(...result.errors) + } + } catch (err) { + errorMessages.push(err?.data?.error || err?.message || String(err)) + } + } + return { totalAdded, totalSkipped, errorMessages } } /** * Build a concise summary from quick-add counts. */ function formatQuickAddSummary(added, skipped, failedCount, firstErrorMessage) { - const parts = []; - if (added > 0) parts.push(`${added} added`); - if (skipped > 0) parts.push(`${skipped} already in dataset`); - if (failedCount > 0) { - parts.push(`${failedCount} failed`); - if (firstErrorMessage != null) { - const text = String(firstErrorMessage); - if (text) parts.push(`: ${text}`); - } - } - return parts.length ? `${parts.join(", ")}.` : "Done."; + const parts = [] + if (added > 0) parts.push(`${added} added`) + if (skipped > 0) parts.push(`${skipped} already in dataset`) + if (failedCount > 0) { + parts.push(`${failedCount} failed`) + if (firstErrorMessage != null) { + const text = String(firstErrorMessage) + if (text) parts.push(`: ${text}`) + } + } + return parts.length ? `${parts.join(", ")}.` : "Done." } if (typeof window !== "undefined") { - window.QuickAddApi = { - postQuickAddCapture, - postQuickAddCaptures, - formatQuickAddSummary, - }; + window.QuickAddApi = { + postQuickAddCapture, + postQuickAddCaptures, + formatQuickAddSummary, + } } if (typeof module !== "undefined" && module.exports) { - module.exports = { - postQuickAddCapture, - postQuickAddCaptures, - formatQuickAddSummary, - }; + module.exports = { + postQuickAddCapture, + postQuickAddCaptures, + formatQuickAddSummary, + } } diff --git a/gateway/sds_gateway/static/js/constants/FileListConfig.js b/gateway/sds_gateway/static/js/constants/FileListConfig.js index 9dd669c04..0a149b72c 100644 --- a/gateway/sds_gateway/static/js/constants/FileListConfig.js +++ b/gateway/sds_gateway/static/js/constants/FileListConfig.js @@ -4,18 +4,18 @@ */ /** Query params for capture list (ListCapturesView GET). */ const CAPTURE_LIST_PARAM_SPECS = [ - { param: "search", elementId: "search-input" }, - { param: "date_start", elementId: "start_date" }, - { param: "date_end", elementId: "end_date" }, - { param: "min_freq", elementId: "centerFreqMinInput" }, - { param: "max_freq", elementId: "centerFreqMaxInput" }, -]; + { param: "search", elementId: "search-input" }, + { param: "date_start", elementId: "start_date" }, + { param: "date_end", elementId: "end_date" }, + { param: "min_freq", elementId: "centerFreqMinInput" }, + { param: "max_freq", elementId: "centerFreqMaxInput" }, +] -window.FileListConfig = {CAPTURE_LIST_PARAM_SPECS}; +window.FileListConfig = { CAPTURE_LIST_PARAM_SPECS } if (typeof module !== "undefined" && module.exports) { - module.exports = { - FileListConfig: window.FileListConfig, - CAPTURE_LIST_PARAM_SPECS, - }; + module.exports = { + FileListConfig: window.FileListConfig, + CAPTURE_LIST_PARAM_SPECS, + } } diff --git a/gateway/sds_gateway/static/js/constants/PermissionLevels.js b/gateway/sds_gateway/static/js/constants/PermissionLevels.js index 4fda6ab70..38c1981de 100644 --- a/gateway/sds_gateway/static/js/constants/PermissionLevels.js +++ b/gateway/sds_gateway/static/js/constants/PermissionLevels.js @@ -3,26 +3,26 @@ * These should be kept in sync with the backend enum values. */ window.PermissionLevels = { - OWNER: "owner", - CO_OWNER: "co-owner", - CONTRIBUTOR: "contributor", - VIEWER: "viewer", -}; + OWNER: "owner", + CO_OWNER: "co-owner", + CONTRIBUTOR: "contributor", + VIEWER: "viewer", +} /** * Array of all permission levels in order of hierarchy (highest to lowest) */ window.PERMISSION_OPTIONS = [ - window.PermissionLevels.OWNER, - window.PermissionLevels.CO_OWNER, - window.PermissionLevels.CONTRIBUTOR, - window.PermissionLevels.VIEWER, -]; + window.PermissionLevels.OWNER, + window.PermissionLevels.CO_OWNER, + window.PermissionLevels.CONTRIBUTOR, + window.PermissionLevels.VIEWER, +] /** Levels that can be assigned when sharing with new users (owner is implicit). */ window.SHARE_PERMISSION_OPTIONS = window.PERMISSION_OPTIONS.filter( - (level) => level !== window.PermissionLevels.OWNER, -); + (level) => level !== window.PermissionLevels.OWNER, +) /** * Check if a permission level is valid @@ -30,4 +30,4 @@ window.SHARE_PERMISSION_OPTIONS = window.PERMISSION_OPTIONS.filter( * @returns {boolean} - True if valid, false otherwise */ window.isValidPermissionLevel = (level) => - Object.values(window.PermissionLevels).includes(level); + Object.values(window.PermissionLevels).includes(level) diff --git a/gateway/sds_gateway/static/js/constants/__tests__/detailsModalConfig.test.js b/gateway/sds_gateway/static/js/constants/__tests__/detailsModalConfig.test.js index f78f7ec04..371afd4c4 100644 --- a/gateway/sds_gateway/static/js/constants/__tests__/detailsModalConfig.test.js +++ b/gateway/sds_gateway/static/js/constants/__tests__/detailsModalConfig.test.js @@ -2,79 +2,79 @@ * Jest tests for detailsModalConfig registry */ -require("../detailsModalConfig.js"); +require("../detailsModalConfig.js") describe("detailsModalConfig", () => { - beforeEach(() => { - jest.clearAllMocks(); - document.body.innerHTML = ` + beforeEach(() => { + jest.clearAllMocks() + document.body.innerHTML = `
- `; - }); + ` + }) - const registry = () => window.DetailsModalAssetRegistry; + const registry = () => window.DetailsModalAssetRegistry - test("capture buildDetailsUrl uses users details-modal path", () => { - expect(registry().capture.buildDetailsUrl("abc-def")).toBe( - "/users/details-modal/capture/abc-def/", - ); - }); + test("capture buildDetailsUrl uses users details-modal path", () => { + expect(registry().capture.buildDetailsUrl("abc-def")).toBe( + "/users/details-modal/capture/abc-def/", + ) + }) - test("dataset buildDetailsUrl uses users details-modal path", () => { - expect(registry().dataset.buildDetailsUrl("ds-1")).toBe( - "/users/details-modal/dataset/ds-1/", - ); - }); + test("dataset buildDetailsUrl uses users details-modal path", () => { + expect(registry().dataset.buildDetailsUrl("ds-1")).toBe( + "/users/details-modal/dataset/ds-1/", + ) + }) - test("capture resolveUuidFromTrigger prefers data-item-uuid", () => { - const el = document.createElement("button"); - el.setAttribute("data-item-uuid", "item"); - el.setAttribute("data-capture-uuid", "cap"); - el.setAttribute("data-uuid", "plain"); - expect(registry().capture.resolveUuidFromTrigger(el)).toBe("item"); - }); + test("capture resolveUuidFromTrigger prefers data-item-uuid", () => { + const el = document.createElement("button") + el.setAttribute("data-item-uuid", "item") + el.setAttribute("data-capture-uuid", "cap") + el.setAttribute("data-uuid", "plain") + expect(registry().capture.resolveUuidFromTrigger(el)).toBe("item") + }) - test("dataset resolveUuidFromTrigger reads row data-dataset-uuid", () => { - document.body.innerHTML = ` + test("dataset resolveUuidFromTrigger reads row data-dataset-uuid", () => { + document.body.innerHTML = `
- `; - const btn = document.querySelector(".inner"); - expect(registry().dataset.resolveUuidFromTrigger(btn)).toBe("row-uuid"); - }); + ` + const btn = document.querySelector(".inner") + expect(registry().dataset.resolveUuidFromTrigger(btn)).toBe("row-uuid") + }) - test("capture resolveShell returns modal body and title elements", () => { - const shell = registry().capture.resolveShell(); - expect(shell.modal.id).toBe("asset-details-modal"); - expect(shell.bodyEl.id).toBe("asset-details-modal-body"); - expect(shell.titleEl.id).toBe("asset-details-modal-label"); - }); + test("capture resolveShell returns modal body and title elements", () => { + const shell = registry().capture.resolveShell() + expect(shell.modal.id).toBe("asset-details-modal") + expect(shell.bodyEl.id).toBe("asset-details-modal-body") + expect(shell.titleEl.id).toBe("asset-details-modal-label") + }) - test("capture afterInject calls CaptureDetailsModalBehavior when present", () => { - const afterInject = jest.fn(); - window.CaptureDetailsModalBehavior = { afterInject }; - const modal = document.getElementById("asset-details-modal"); - registry().capture.afterInject({ modal, meta: { uuid: "c1" } }); - expect(afterInject).toHaveBeenCalledWith( - expect.objectContaining({ modal, meta: { uuid: "c1" } }), - ); - }); + test("capture afterInject calls CaptureDetailsModalBehavior when present", () => { + const afterInject = jest.fn() + window.CaptureDetailsModalBehavior = { afterInject } + const modal = document.getElementById("asset-details-modal") + registry().capture.afterInject({ modal, meta: { uuid: "c1" } }) + expect(afterInject).toHaveBeenCalledWith( + expect.objectContaining({ modal, meta: { uuid: "c1" } }), + ) + }) - test("dataset afterInject wires uuid copy on modal", () => { - const attachUuidCopyButton = jest.fn(); - window.DetailsActionManager = { attachUuidCopyButton }; - const modal = document.getElementById("asset-details-modal"); - registry().dataset.afterInject({ - modal, - meta: { uuid: "d-99" }, - }); - expect(attachUuidCopyButton).toHaveBeenCalledWith(modal, "d-99"); - }); -}); + test("dataset afterInject wires uuid copy on modal", () => { + const attachUuidCopyButton = jest.fn() + window.DetailsActionManager = { attachUuidCopyButton } + const modal = document.getElementById("asset-details-modal") + registry().dataset.afterInject({ + modal, + meta: { uuid: "d-99" }, + }) + expect(attachUuidCopyButton).toHaveBeenCalledWith(modal, "d-99") + }) +}) diff --git a/gateway/sds_gateway/static/js/constants/detailsModalConfig.js b/gateway/sds_gateway/static/js/constants/detailsModalConfig.js index 75b4d0f5e..88b085a05 100644 --- a/gateway/sds_gateway/static/js/constants/detailsModalConfig.js +++ b/gateway/sds_gateway/static/js/constants/detailsModalConfig.js @@ -2,83 +2,83 @@ * Registry for details modal loading (asset type → URLs + trigger resolution). * ModalManager reads window.DetailsModalAssetRegistry — no branching on asset type there. */ -(function attachDetailsModalAssetRegistry(global) { - function resolveAssetDetailsShell() { - const modal = document.getElementById("asset-details-modal"); - if (!modal) return null; - return { - modal, - titleEl: - document.getElementById("asset-details-modal-label") || - modal.querySelector(".modal-title"), - bodyEl: - document.getElementById("asset-details-modal-body") || - modal.querySelector(".modal-body"), - }; - } +;(function attachDetailsModalAssetRegistry(global) { + function resolveAssetDetailsShell() { + const modal = document.getElementById("asset-details-modal") + if (!modal) return null + return { + modal, + titleEl: + document.getElementById("asset-details-modal-label") || + modal.querySelector(".modal-title"), + bodyEl: + document.getElementById("asset-details-modal-body") || + modal.querySelector(".modal-body"), + } + } - const capture = { - assetType: "capture", - delegateClickSelectors: [ - ".capture-details-btn", - ".capture-link", - ".view-capture-btn", - ], - buildDetailsUrl(uuid) { - return `/users/details-modal/capture/${encodeURIComponent(uuid)}/`; - }, - resolveUuidFromTrigger(el) { - if (!el) return ""; - return ( - el.getAttribute("data-item-uuid") || - el.getAttribute("data-capture-uuid") || - el.getAttribute("data-uuid") || - "" - ); - }, - resolveShell() { - return resolveAssetDetailsShell(); - }, - afterInject(ctx) { - if (window.CaptureDetailsModalBehavior?.afterInject) { - window.CaptureDetailsModalBehavior.afterInject(ctx); - } - }, - loadingTitle: "Loading capture details...", - }; + const capture = { + assetType: "capture", + delegateClickSelectors: [ + ".capture-details-btn", + ".capture-link", + ".view-capture-btn", + ], + buildDetailsUrl(uuid) { + return `/users/details-modal/capture/${encodeURIComponent(uuid)}/` + }, + resolveUuidFromTrigger(el) { + if (!el) return "" + return ( + el.getAttribute("data-item-uuid") || + el.getAttribute("data-capture-uuid") || + el.getAttribute("data-uuid") || + "" + ) + }, + resolveShell() { + return resolveAssetDetailsShell() + }, + afterInject(ctx) { + if (window.CaptureDetailsModalBehavior?.afterInject) { + window.CaptureDetailsModalBehavior.afterInject(ctx) + } + }, + loadingTitle: "Loading capture details...", + } - const dataset = { - assetType: "dataset", - delegateClickSelectors: [".dataset-details-open"], - buildDetailsUrl(uuid) { - return `/users/details-modal/dataset/${encodeURIComponent(uuid)}/`; - }, - resolveUuidFromTrigger(el) { - if (!el) return ""; - const row = el.closest?.(".dataset-details-open"); - const src = row || el; - return ( - src.getAttribute("data-dataset-uuid") || - src.getAttribute("data-item-uuid") || - "" - ); - }, - resolveShell() { - return resolveAssetDetailsShell(); - }, - afterInject(ctx) { - if (window.DetailsActionManager?.attachUuidCopyButton) { - window.DetailsActionManager.attachUuidCopyButton( - ctx.modal, - ctx.meta?.uuid, - ); - } - }, - loadingTitle: "Loading dataset details...", - }; + const dataset = { + assetType: "dataset", + delegateClickSelectors: [".dataset-details-open"], + buildDetailsUrl(uuid) { + return `/users/details-modal/dataset/${encodeURIComponent(uuid)}/` + }, + resolveUuidFromTrigger(el) { + if (!el) return "" + const row = el.closest?.(".dataset-details-open") + const src = row || el + return ( + src.getAttribute("data-dataset-uuid") || + src.getAttribute("data-item-uuid") || + "" + ) + }, + resolveShell() { + return resolveAssetDetailsShell() + }, + afterInject(ctx) { + if (window.DetailsActionManager?.attachUuidCopyButton) { + window.DetailsActionManager.attachUuidCopyButton( + ctx.modal, + ctx.meta?.uuid, + ) + } + }, + loadingTitle: "Loading dataset details...", + } - global.DetailsModalAssetRegistry = { - capture, - dataset, - }; -})(typeof window !== "undefined" ? window : globalThis); + global.DetailsModalAssetRegistry = { + capture, + dataset, + } +})(typeof window !== "undefined" ? window : globalThis) diff --git a/gateway/sds_gateway/static/js/core/APIClient.js b/gateway/sds_gateway/static/js/core/APIClient.js index aa2bceafa..077bd2235 100644 --- a/gateway/sds_gateway/static/js/core/APIClient.js +++ b/gateway/sds_gateway/static/js/core/APIClient.js @@ -5,439 +5,451 @@ * Handles CSRF tokens, error handling, and loading states consistently */ class APIClient { - /** - * Get CSRF token from various sources - * @returns {string} CSRF token - */ - getCSRFToken() { - // Try meta tag first (most reliable) - const metaToken = document.querySelector('meta[name="csrf-token"]'); - if (metaToken) { - return metaToken.getAttribute("content"); - } - - // Fallback to input field - const inputToken = document.querySelector('[name="csrfmiddlewaretoken"]'); - if (inputToken) { - return inputToken.value; - } - - // Last resort: try cookie - const cookieToken = this.getCookie("csrftoken"); - if (cookieToken) { - return cookieToken; - } - - console.error("CSRF token not found"); - return ""; - } - - /** - * Get cookie value by name - * @param {string} name - Cookie name - * @returns {string|null} Cookie value - */ - getCookie(name) { - let cookieValue = null; - if (document.cookie && document.cookie !== "") { - const cookies = document.cookie.split(";"); - for (let i = 0; i < cookies.length; i++) { - const cookie = cookies[i].trim(); - if (cookie.substring(0, name.length + 1) === `${name}=`) { - cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); - break; - } - } - } - return cookieValue; - } - - /** - * Make API request with consistent error handling and CSRF - * @param {string} url - Request URL - * @param {Object} options - Fetch options - * @param {Object} loadingState - Loading state management object - * @returns {Promise} Response data - */ - async request(url, options = {}, loadingState = null) { - // Set loading state - if (loadingState) { - loadingState.setLoading(true); - } - - // Prepare headers - const headers = { - "X-Requested-With": "XMLHttpRequest", - ...options.headers, - }; - - // Add CSRF token for non-GET requests - if (options.method && options.method !== "GET") { - headers["X-CSRFToken"] = this.getCSRFToken(); - } - - try { - const response = await fetch(url, { - ...options, - headers, - }); - - // Handle non-OK responses - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new APIError( - `HTTP ${response.status}: ${response.statusText}`, - response.status, - errorData, - ); - } - - // Parse response - const contentType = response.headers.get("content-type"); - if (contentType?.includes("application/json")) { - return await response.json(); - } - return await response.text(); - } catch (error) { - // Handle network errors - if (error instanceof APIError) { - throw error; - } - throw new APIError(`Network error: ${error.message}`, 0, {}); - } finally { - // Clear loading state - if (loadingState) { - loadingState.setLoading(false); - } - } - } - - /** - * Make GET request - * @param {string} url - Request URL - * @param {Object} params - Query parameters - * @param {Object} loadingState - Loading state management - * @returns {Promise} Response data - */ - async get(url, params = {}, loadingState = null) { - const urlObj = new URL(url, window.location.origin); - for (const [key, value] of Object.entries(params)) { - if (value !== null && value !== undefined) { - urlObj.searchParams.append(key, value); - } - } - - return this.request(urlObj.toString(), { method: "GET" }, loadingState); - } - - /** - * @param {Record} data - * @returns {FormData} - */ - _formDataFromObject(data) { - const formData = new FormData(); - for (const [key, value] of Object.entries(data)) { - if (value !== null && value !== undefined) { - formData.append(key, value); - } - } - return formData; - } - - /** - * Make POST request - * @param {string} url - Request URL - * @param {Object} data - Request data - * @param {Object} loadingState - Loading state management - * @param {boolean} asJson - Whether to send as JSON (default: false, sends as form data) - * @returns {Promise} Response data - */ - async post(url, data = {}, loadingState = null, asJson = false) { - if (asJson) { - return this.request( - url, - { - method: "POST", - body: JSON.stringify(data), - headers: { - "Content-Type": "application/json", - }, - }, - loadingState, - ); - } - - const formData = this._formDataFromObject(data); - - return this.request( - url, - { - method: "POST", - body: formData, - }, - loadingState, - ); - } - - /** - * Make PATCH request - * @param {string} url - Request URL - * @param {Object} data - Request data - * @param {Object} loadingState - Loading state management - * @returns {Promise} Response data - */ - async patch(url, data = {}, loadingState = null) { - const formData = this._formDataFromObject(data); - - return this.request( - url, - { - method: "PATCH", - body: formData, - }, - loadingState, - ); - } - - /** - * Make PUT request - * @param {string} url - Request URL - * @param {Object} data - Request data - * @param {Object} loadingState - Loading state management - * @returns {Promise} Response data - */ - async put(url, data = {}, loadingState = null) { - const formData = this._formDataFromObject(data); - - return this.request( - url, - { - method: "PUT", - body: formData, - }, - loadingState, - ); - } + /** + * Get CSRF token from various sources + * @returns {string} CSRF token + */ + getCSRFToken() { + // Try meta tag first (most reliable) + const metaToken = document.querySelector('meta[name="csrf-token"]') + if (metaToken) { + return metaToken.getAttribute("content") + } + + // Fallback to input field + const inputToken = document.querySelector( + '[name="csrfmiddlewaretoken"]', + ) + if (inputToken) { + return inputToken.value + } + + // Last resort: try cookie + const cookieToken = this.getCookie("csrftoken") + if (cookieToken) { + return cookieToken + } + + console.error("CSRF token not found") + return "" + } + + /** + * Get cookie value by name + * @param {string} name - Cookie name + * @returns {string|null} Cookie value + */ + getCookie(name) { + let cookieValue = null + if (document.cookie && document.cookie !== "") { + const cookies = document.cookie.split(";") + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim() + if (cookie.substring(0, name.length + 1) === `${name}=`) { + cookieValue = decodeURIComponent( + cookie.substring(name.length + 1), + ) + break + } + } + } + return cookieValue + } + + /** + * Make API request with consistent error handling and CSRF + * @param {string} url - Request URL + * @param {Object} options - Fetch options + * @param {Object} loadingState - Loading state management object + * @returns {Promise} Response data + */ + async request(url, options = {}, loadingState = null) { + // Set loading state + if (loadingState) { + loadingState.setLoading(true) + } + + // Prepare headers + const headers = { + "X-Requested-With": "XMLHttpRequest", + ...options.headers, + } + + // Add CSRF token for non-GET requests + if (options.method && options.method !== "GET") { + headers["X-CSRFToken"] = this.getCSRFToken() + } + + try { + const response = await fetch(url, { + ...options, + headers, + }) + + // Handle non-OK responses + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new APIError( + `HTTP ${response.status}: ${response.statusText}`, + response.status, + errorData, + ) + } + + // Parse response + const contentType = response.headers.get("content-type") + if (contentType?.includes("application/json")) { + return await response.json() + } + return await response.text() + } catch (error) { + // Handle network errors + if (error instanceof APIError) { + throw error + } + throw new APIError(`Network error: ${error.message}`, 0, {}) + } finally { + // Clear loading state + if (loadingState) { + loadingState.setLoading(false) + } + } + } + + /** + * Make GET request + * @param {string} url - Request URL + * @param {Object} params - Query parameters + * @param {Object} loadingState - Loading state management + * @returns {Promise} Response data + */ + async get(url, params = {}, loadingState = null) { + const urlObj = new URL(url, window.location.origin) + for (const [key, value] of Object.entries(params)) { + if (value !== null && value !== undefined) { + urlObj.searchParams.append(key, value) + } + } + + return this.request(urlObj.toString(), { method: "GET" }, loadingState) + } + + /** + * @param {Record} data + * @returns {FormData} + */ + _formDataFromObject(data) { + const formData = new FormData() + for (const [key, value] of Object.entries(data)) { + if (value !== null && value !== undefined) { + formData.append(key, value) + } + } + return formData + } + + /** + * Make POST request + * @param {string} url - Request URL + * @param {Object} data - Request data + * @param {Object} loadingState - Loading state management + * @param {boolean} asJson - Whether to send as JSON (default: false, sends as form data) + * @returns {Promise} Response data + */ + async post(url, data = {}, loadingState = null, asJson = false) { + if (asJson) { + return this.request( + url, + { + method: "POST", + body: JSON.stringify(data), + headers: { + "Content-Type": "application/json", + }, + }, + loadingState, + ) + } + + const formData = this._formDataFromObject(data) + + return this.request( + url, + { + method: "POST", + body: formData, + }, + loadingState, + ) + } + + /** + * Make PATCH request + * @param {string} url - Request URL + * @param {Object} data - Request data + * @param {Object} loadingState - Loading state management + * @returns {Promise} Response data + */ + async patch(url, data = {}, loadingState = null) { + const formData = this._formDataFromObject(data) + + return this.request( + url, + { + method: "PATCH", + body: formData, + }, + loadingState, + ) + } + + /** + * Make PUT request + * @param {string} url - Request URL + * @param {Object} data - Request data + * @param {Object} loadingState - Loading state management + * @returns {Promise} Response data + */ + async put(url, data = {}, loadingState = null) { + const formData = this._formDataFromObject(data) + + return this.request( + url, + { + method: "PUT", + body: formData, + }, + loadingState, + ) + } } /** * Custom API Error class */ class APIError extends Error { - constructor(message, status, data) { - super(message); - this.name = "APIError"; - this.status = status; - this.data = data; - } + constructor(message, status, data) { + super(message) + this.name = "APIError" + this.status = status + this.data = data + } } /** * Loading State Manager */ class LoadingStateManager { - constructor(element) { - this.element = element; - this.originalContent = element ? element.innerHTML : ""; - this.isLoading = false; - } - - setLoading(loading) { - if (this.isLoading === loading) return; - - this.isLoading = loading; - - if (!this.element) return; - - if (loading) { - this.element.disabled = true; - this.element.innerHTML = - 'Loading...'; - } else { - this.element.disabled = false; - this.element.innerHTML = this.originalContent; - } - } + constructor(element) { + this.element = element + this.originalContent = element ? element.innerHTML : "" + this.isLoading = false + } + + setLoading(loading) { + if (this.isLoading === loading) return + + this.isLoading = loading + + if (!this.element) return + + if (loading) { + this.element.disabled = true + this.element.innerHTML = + 'Loading...' + } else { + this.element.disabled = false + this.element.innerHTML = this.originalContent + } + } } /** Separator in list-refresh AJAX response between table HTML and modals HTML */ -const LIST_REFRESH_SEP = ""; +const LIST_REFRESH_SEP = "" /** * DatasetListManager - Handles dataset list table loading and updates */ class ListRefreshManager { - /** - * Initialize dataset list manager - * @param {Object} config - Configuration object - * @param {string} config.containerSelector - Selector for the container element (default: '#dynamic-table-container') - * @param {string} [config.modalsContainerSelector] - Optional selector for modals container; when set, response is expected to contain LIST_REFRESH_SEP - * @param {string} config.url - Base URL for dataset list endpoint (default: '/users/dataset-list/') - */ - constructor(config = {}) { - this.containerSelector = config.containerSelector; - this.modalsContainerSelector = config.modalsContainerSelector || null; - this.url = config.url; - this.itemType = config.itemType; - this.container = document.querySelector(this.containerSelector); - this.modalsContainer = this.modalsContainerSelector - ? document.querySelector(this.modalsContainerSelector) - : null; - } - - /** - * Load dataset list table via AJAX GET request - * @param {Object} params - Query parameters - * @param {number} params.page - Page number (default: 1) - * @param {string} params.sort_by - Sort field: 'name' | 'created_at' | 'updated_at' | 'authors' (default: 'created_at') - * @param {string} params.sort_order - Sort order: 'asc' | 'desc' (default: 'desc') - * @param {Object} options - Additional options - * @param {boolean} options.showLoading - Show loading state (default: true) - * @param {Function} options.onSuccess - Callback on success - * @param {Function} options.onError - Callback on error - * @returns {Promise} HTML content of the table - */ - async loadTable(params = {}, options = {}) { - const { - showLoading = true, - onSuccess = null, - onError = null, - loadingMessage = null, - } = options; - - const queryParams = { - page: 1, - sort_by: "created_at", - sort_order: "desc", - ...params, - }; - - // Validate container exists - if (!this.container) { - const error = new Error(`Container not found: ${this.containerSelector}`); - console.error(error.message); - if (onError) { - onError(error); - } - throw error; - } - - // Show loading state if requested - if (showLoading) { - const msg = - loadingMessage || - `Loading ${this.itemType ? `${this.itemType} ` : ""}list...`; - await window.DOMUtils.renderLoading(this.container, msg, { - format: "spinner", - size: "sm", - }); - } - - try { - // Make GET request with query parameters - const html = await window.APIClient.get(this.url, queryParams); - - // Update container(s) with HTML response - if (typeof html === "string") { - let tableHtml = html; - let modalsHtml = ""; - - if (this.modalsContainer && html.includes(LIST_REFRESH_SEP)) { - const parts = html.split(LIST_REFRESH_SEP); - tableHtml = parts[0]; - modalsHtml = parts[1] ?? ""; - this.modalsContainer.innerHTML = modalsHtml; - } - - this.container.innerHTML = tableHtml; - - await this._reinitializeEventListeners(); - - // Call success callback if provided - if (onSuccess) { - onSuccess(tableHtml); - } - - return tableHtml; - } - throw new Error("Invalid response format: expected HTML string"); - } catch (error) { - console.error(`Error loading ${this.itemType} list table:`, error); - - // Show error state - await window.DOMUtils.showMessage( - `Failed to load ${this.itemType} list. Please try again.`, - { - variant: "danger", - placement: "replace", - target: this.container, - presentation: "alert", - }, - ); - - // Call error callback if provided - if (onError) { - onError(error); - } - - throw error; - } - } - - /** - * Re-initialize event listeners after table update - * This ensures modals, dropdowns, and other interactive elements work after AJAX updates - */ - async _reinitializeEventListeners() { - // Re-initialize Bootstrap dropdowns - if (typeof bootstrap !== "undefined" && bootstrap.Dropdown) { - window.DOMUtils.initIconDropdowns(this.container ?? document); - } - - // Bootstrap modal instances on replaced markup (share/version/download shells) - window.ModalManager?.initializeModal?.({ bootstrap: true, root: document }); - - // Re-initialize tooltips if Bootstrap tooltips are available - if (typeof bootstrap !== "undefined" && bootstrap.Tooltip) { - for (const element of document.querySelectorAll( - '[data-bs-toggle="tooltip"]', - )) { - // Dispose existing tooltip if any - const existing = bootstrap.Tooltip.getInstance(element); - if (existing) { - existing.dispose(); - } - - // Create new tooltip instance - new bootstrap.Tooltip(element); - } - } - - // Page lifecycle: full re-init so ShareActionManager / VersioningActionManager re-bind - if (window.pageLifecycleManager?.refresh) { - try { - await window.pageLifecycleManager.refresh(); - } catch (error) { - console.error("PageLifecycleManager.refresh failed after list update:", error); - } - } - } + /** + * Initialize dataset list manager + * @param {Object} config - Configuration object + * @param {string} config.containerSelector - Selector for the container element (default: '#dynamic-table-container') + * @param {string} [config.modalsContainerSelector] - Optional selector for modals container; when set, response is expected to contain LIST_REFRESH_SEP + * @param {string} config.url - Base URL for dataset list endpoint (default: '/users/dataset-list/') + */ + constructor(config = {}) { + this.containerSelector = config.containerSelector + this.modalsContainerSelector = config.modalsContainerSelector || null + this.url = config.url + this.itemType = config.itemType + this.container = document.querySelector(this.containerSelector) + this.modalsContainer = this.modalsContainerSelector + ? document.querySelector(this.modalsContainerSelector) + : null + } + + /** + * Load dataset list table via AJAX GET request + * @param {Object} params - Query parameters + * @param {number} params.page - Page number (default: 1) + * @param {string} params.sort_by - Sort field: 'name' | 'created_at' | 'updated_at' | 'authors' (default: 'created_at') + * @param {string} params.sort_order - Sort order: 'asc' | 'desc' (default: 'desc') + * @param {Object} options - Additional options + * @param {boolean} options.showLoading - Show loading state (default: true) + * @param {Function} options.onSuccess - Callback on success + * @param {Function} options.onError - Callback on error + * @returns {Promise} HTML content of the table + */ + async loadTable(params = {}, options = {}) { + const { + showLoading = true, + onSuccess = null, + onError = null, + loadingMessage = null, + } = options + + const queryParams = { + page: 1, + sort_by: "created_at", + sort_order: "desc", + ...params, + } + + // Validate container exists + if (!this.container) { + const error = new Error( + `Container not found: ${this.containerSelector}`, + ) + console.error(error.message) + if (onError) { + onError(error) + } + throw error + } + + // Show loading state if requested + if (showLoading) { + const msg = + loadingMessage || + `Loading ${this.itemType ? `${this.itemType} ` : ""}list...` + await window.DOMUtils.renderLoading(this.container, msg, { + format: "spinner", + size: "sm", + }) + } + + try { + // Make GET request with query parameters + const html = await window.APIClient.get(this.url, queryParams) + + // Update container(s) with HTML response + if (typeof html === "string") { + let tableHtml = html + let modalsHtml = "" + + if (this.modalsContainer && html.includes(LIST_REFRESH_SEP)) { + const parts = html.split(LIST_REFRESH_SEP) + tableHtml = parts[0] + modalsHtml = parts[1] ?? "" + this.modalsContainer.innerHTML = modalsHtml + } + + this.container.innerHTML = tableHtml + + await this._reinitializeEventListeners() + + // Call success callback if provided + if (onSuccess) { + onSuccess(tableHtml) + } + + return tableHtml + } + throw new Error("Invalid response format: expected HTML string") + } catch (error) { + console.error(`Error loading ${this.itemType} list table:`, error) + + // Show error state + await window.DOMUtils.showMessage( + `Failed to load ${this.itemType} list. Please try again.`, + { + variant: "danger", + placement: "replace", + target: this.container, + presentation: "alert", + }, + ) + + // Call error callback if provided + if (onError) { + onError(error) + } + + throw error + } + } + + /** + * Re-initialize event listeners after table update + * This ensures modals, dropdowns, and other interactive elements work after AJAX updates + */ + async _reinitializeEventListeners() { + // Re-initialize Bootstrap dropdowns + if (typeof bootstrap !== "undefined" && bootstrap.Dropdown) { + window.DOMUtils.initIconDropdowns(this.container ?? document) + } + + // Bootstrap modal instances on replaced markup (share/version/download shells) + window.ModalManager?.initializeModal?.({ + bootstrap: true, + root: document, + }) + + // Re-initialize tooltips if Bootstrap tooltips are available + if (typeof bootstrap !== "undefined" && bootstrap.Tooltip) { + for (const element of document.querySelectorAll( + '[data-bs-toggle="tooltip"]', + )) { + // Dispose existing tooltip if any + const existing = bootstrap.Tooltip.getInstance(element) + if (existing) { + existing.dispose() + } + + // Create new tooltip instance + new bootstrap.Tooltip(element) + } + } + + // Page lifecycle: full re-init so ShareActionManager / VersioningActionManager re-bind + if (window.pageLifecycleManager?.refresh) { + try { + await window.pageLifecycleManager.refresh() + } catch (error) { + console.error( + "PageLifecycleManager.refresh failed after list update:", + error, + ) + } + } + } } // Create instances and make them available globally -window.APIClient = new APIClient(); -window.APIError = APIError; -window.LoadingStateManager = LoadingStateManager; +window.APIClient = new APIClient() +window.APIError = APIError +window.LoadingStateManager = LoadingStateManager // NOTE: ListRefreshManager is a class and should be instantiated explicitly. -window.ListRefreshManager = ListRefreshManager; +window.ListRefreshManager = ListRefreshManager // Export for ES6 modules (Jest testing) - only if in module context if (typeof module !== "undefined" && module.exports) { - module.exports = { - APIClient, - APIError, - LoadingStateManager, - ListRefreshManager, - LIST_REFRESH_SEP, - }; + module.exports = { + APIClient, + APIError, + LoadingStateManager, + ListRefreshManager, + LIST_REFRESH_SEP, + } } diff --git a/gateway/sds_gateway/static/js/core/BaseManager.js b/gateway/sds_gateway/static/js/core/BaseManager.js index a2f3b5fcb..221a4466a 100644 --- a/gateway/sds_gateway/static/js/core/BaseManager.js +++ b/gateway/sds_gateway/static/js/core/BaseManager.js @@ -2,190 +2,190 @@ * Base class for UI managers: shared services, toast helpers, lifecycle hooks. */ BaseManager = class { - constructor() { - this.showMessage = window.DOMUtils?.showMessage; - this.getCSRFToken = window.APIClient?.getCSRFToken; - this.logError = window.DOMUtils?.logError; - this.getUserFriendlyErrorMessage = - window.DOMUtils?.getUserFriendlyErrorMessage; + constructor() { + this.showMessage = window.DOMUtils?.showMessage + this.getCSRFToken = window.APIClient?.getCSRFToken + this.logError = window.DOMUtils?.logError + this.getUserFriendlyErrorMessage = + window.DOMUtils?.getUserFriendlyErrorMessage - this.successMessage = (message) => - this.showMessage?.(message, { - variant: "success", - placement: "toast", - presentation: "toast", - }); + this.successMessage = (message) => + this.showMessage?.(message, { + variant: "success", + placement: "toast", + presentation: "toast", + }) - this.errorMessage = (message) => - this.showMessage?.(message, { - variant: "danger", - placement: "toast", - presentation: "toast", - }); + this.errorMessage = (message) => + this.showMessage?.(message, { + variant: "danger", + placement: "toast", + presentation: "toast", + }) - this.warningMessage = (message) => - this.showMessage?.(message, { - variant: "warning", - placement: "toast", - presentation: "toast", - }); + this.warningMessage = (message) => + this.showMessage?.(message, { + variant: "warning", + placement: "toast", + presentation: "toast", + }) - this.infoMessage = (message) => - this.showMessage?.(message, { - variant: "info", - placement: "toast", - presentation: "toast", - }); + this.infoMessage = (message) => + this.showMessage?.(message, { + variant: "info", + placement: "toast", + presentation: "toast", + }) - if (!this.checkBrowserSupport()) { - void window.DOMUtils?.showMessage( - "Your browser doesn't support required features. Please use a modern browser.", - { - variant: "danger", - placement: "toast", - log: true, - error: new Error("Browser compatibility check failed"), - triggeredBy: document.body, - }, - ); - return; - } - } + if (!this.checkBrowserSupport()) { + void window.DOMUtils?.showMessage( + "Your browser doesn't support required features. Please use a modern browser.", + { + variant: "danger", + placement: "toast", + log: true, + error: new Error("Browser compatibility check failed"), + triggeredBy: document.body, + }, + ) + return + } + } - /** - * Override in subclasses that require feature detection (e.g. uploads). - * @returns {boolean} - */ - checkBrowserSupport() { - return true; - } + /** + * Override in subclasses that require feature detection (e.g. uploads). + * @returns {boolean} + */ + checkBrowserSupport() { + return true + } - /** - * Normalize semantic type and show a toast via {@link DOMUtils.showMessage}. - * @param {string} message - * @param {string} [type] - */ - showToast(message, type = "success") { - const variant = - type === "danger" || type === "error" - ? "danger" - : type === "warning" || type === "success" || type === "info" - ? type - : "info"; - if (this.showMessage) { - void this.showMessage(message, { - variant, - placement: "toast", - presentation: "toast", - }); - } else if (window.DOMUtils?.showMessage) { - void window.DOMUtils.showMessage(message, { - variant, - placement: "toast", - presentation: "toast", - }); - } else { - console.error("DOMUtils.showMessage not available"); - } - } + /** + * Normalize semantic type and show a toast via {@link DOMUtils.showMessage}. + * @param {string} message + * @param {string} [type] + */ + showToast(message, type = "success") { + const variant = + type === "danger" || type === "error" + ? "danger" + : type === "warning" || type === "success" || type === "info" + ? type + : "info" + if (this.showMessage) { + void this.showMessage(message, { + variant, + placement: "toast", + presentation: "toast", + }) + } else if (window.DOMUtils?.showMessage) { + void window.DOMUtils.showMessage(message, { + variant, + placement: "toast", + presentation: "toast", + }) + } else { + console.error("DOMUtils.showMessage not available") + } + } - /** - * Danger toast with optional error logging (uses {@link DOMUtils.showMessage} options). - * @param {string} message - * @param {Element|string|null} [contextOrTrigger] - element for log context - * @param {Error|null} [error] - */ - showErrorToast(message, contextOrTrigger = null, error = null) { - const triggeredBy = - contextOrTrigger instanceof Element ? contextOrTrigger : null; - const opts = { - variant: "danger", - placement: "toast", - presentation: "toast", - log: Boolean(error), - error: error || null, - triggeredBy, - }; - if (this.showMessage) { - void this.showMessage(message, opts); - } else if (window.DOMUtils?.showMessage) { - void window.DOMUtils.showMessage(message, opts); - } - } + /** + * Danger toast with optional error logging (uses {@link DOMUtils.showMessage} options). + * @param {string} message + * @param {Element|string|null} [contextOrTrigger] - element for log context + * @param {Error|null} [error] + */ + showErrorToast(message, contextOrTrigger = null, error = null) { + const triggeredBy = + contextOrTrigger instanceof Element ? contextOrTrigger : null + const opts = { + variant: "danger", + placement: "toast", + presentation: "toast", + log: Boolean(error), + error: error || null, + triggeredBy, + } + if (this.showMessage) { + void this.showMessage(message, opts) + } else if (window.DOMUtils?.showMessage) { + void window.DOMUtils.showMessage(message, opts) + } + } - /** - * Server-rendered message inside a container ({@link DOMUtils.showMessage} replace). - * @param {string} message - * @param {Element|string|null} target - * @param {object} [opts] - * @param {string} [opts.variant='danger'] - * @param {string} [opts.presentation='alert'] - * @param {object} [opts.templateContext] - * @returns {Promise} - */ - showMessageInTarget(message, target, opts = {}) { - const { - variant = "danger", - presentation = "alert", - templateContext = {}, - } = opts; - const el = - typeof target === "string" ? document.querySelector(target) : target; - if (!el) { - return Promise.resolve(false); - } - const alertType = - variant === "error" || variant === "danger" - ? "danger" - : variant === "success" || - variant === "warning" || - variant === "info" - ? variant - : "danger"; - const show = this.showMessage || window.DOMUtils?.showMessage; - if (!show) { - return Promise.resolve(false); - } - const fn = - show === window.DOMUtils?.showMessage - ? window.DOMUtils.showMessage.bind(window.DOMUtils) - : show.bind(this); - return fn(message, { - variant: alertType === "danger" ? "danger" : variant, - placement: "replace", - target: el, - presentation, - templateContext: { - ...(presentation === "alert" ? { alert_type: alertType } : {}), - ...templateContext, - }, - }); - } + /** + * Server-rendered message inside a container ({@link DOMUtils.showMessage} replace). + * @param {string} message + * @param {Element|string|null} target + * @param {object} [opts] + * @param {string} [opts.variant='danger'] + * @param {string} [opts.presentation='alert'] + * @param {object} [opts.templateContext] + * @returns {Promise} + */ + showMessageInTarget(message, target, opts = {}) { + const { + variant = "danger", + presentation = "alert", + templateContext = {}, + } = opts + const el = + typeof target === "string" ? document.querySelector(target) : target + if (!el) { + return Promise.resolve(false) + } + const alertType = + variant === "error" || variant === "danger" + ? "danger" + : variant === "success" || + variant === "warning" || + variant === "info" + ? variant + : "danger" + const show = this.showMessage || window.DOMUtils?.showMessage + if (!show) { + return Promise.resolve(false) + } + const fn = + show === window.DOMUtils?.showMessage + ? window.DOMUtils.showMessage.bind(window.DOMUtils) + : show.bind(this) + return fn(message, { + variant: alertType === "danger" ? "danger" : variant, + placement: "replace", + target: el, + presentation, + templateContext: { + ...(presentation === "alert" ? { alert_type: alertType } : {}), + ...templateContext, + }, + }) + } - initialize() { - this.initializeEventListeners(); - } + initialize() { + this.initializeEventListeners() + } - initializeEventListeners() { - // Subclasses override - } + initializeEventListeners() { + // Subclasses override + } - addEventListener(element, event, handler) { - element.addEventListener(event, handler); - } + addEventListener(element, event, handler) { + element.addEventListener(event, handler) + } - removeEventListener(element, event, handler) { - element.removeEventListener(event, handler); - } + removeEventListener(element, event, handler) { + element.removeEventListener(event, handler) + } - cleanup() { - // Subclasses override - } -}; + cleanup() { + // Subclasses override + } +} if (typeof window !== "undefined") { - window.BaseManager = BaseManager; + window.BaseManager = BaseManager } if (typeof module !== "undefined" && module.exports) { - module.exports = { BaseManager }; + module.exports = { BaseManager } } diff --git a/gateway/sds_gateway/static/js/core/DOMUtils.js b/gateway/sds_gateway/static/js/core/DOMUtils.js index ea6be4e41..637d4abbe 100644 --- a/gateway/sds_gateway/static/js/core/DOMUtils.js +++ b/gateway/sds_gateway/static/js/core/DOMUtils.js @@ -22,709 +22,726 @@ * - renderDropdown(options) - Render dropdown menu using Django template */ class DOMUtils { - /** - * Format file size - * @param {number} bytes - File size in bytes - * @returns {string} Formatted file size - */ - formatFileSize(bytes) { - const n = Number(bytes); - if (!Number.isFinite(n) || n < 0) return "0 bytes"; - if (n === 0) return "0 bytes"; - const units = ["bytes", "KB", "MB", "GB"]; - let i = 0; - let v = n; - while (v >= 1024 && i < units.length - 1) { - v /= 1024; - i++; - } - return `${i === 0 ? v : v.toFixed(2)} ${units[i]}`; - } - - /** - * Show element using CSS classes - * @param {Element|string} element - Element or selector to show - * @param {string} displayClass - CSS display class to add (default: "display-block") - */ - show(element, displayClass = "display-block") { - const el = - typeof element === "string" ? document.querySelector(element) : element; - if (!el) { - console.warn("Element not found for show():", element); - return; - } - - el.classList.remove("display-none", "d-none"); - el.classList.add(displayClass); - } - - /** - * Hide element using CSS classes - * @param {Element|string} element - Element or selector to hide - * @param {string} displayClass - CSS display class to remove (default: "display-block") - */ - hide(element, displayClass = "display-block") { - const el = - typeof element === "string" ? document.querySelector(element) : element; - if (!el) { - console.warn("Element not found for hide():", element); - return; - } - - el.classList.remove(displayClass); - el.classList.add("display-none"); - } - - /** - * Remove alert elements inside a container (e.g. modal body). - * @param {Element|string} target - */ - clearAlerts(target) { - const el = - typeof target === "string" ? document.querySelector(target) : target; - if (!el) return; - for (const alert of el.querySelectorAll(".alert")) { - alert.remove(); - } - } - - formatDate(dateString) { - if (!dateString) return "
-
"; - - let date; - if (typeof dateString === "string") { - date = new Date(dateString); - } else { - date = new Date(dateString); - } - - if (!date || Number.isNaN(date.getTime())) { - return "
-
"; - } - - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const year = date.getFullYear(); - const hours = date.getHours(); - const minutes = String(date.getMinutes()).padStart(2, "0"); - const seconds = String(date.getSeconds()).padStart(2, "0"); - const ampm = hours >= 12 ? "PM" : "AM"; - const displayHours = hours % 12 || 12; - - return `
${month}/${day}/${year}
${displayHours}:${minutes}:${seconds} ${ampm}`; - } - - formatDateForModal(dateString) { - if (!dateString || dateString === "None") { - return "N/A"; - } - - try { - const date = new Date(dateString); - if (Number.isNaN(date.getTime())) { - return "N/A"; - } - - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const dateFormatted = `${year}-${month}-${day}`; - - const hours = String(date.getHours()).padStart(2, "0"); - const minutes = String(date.getMinutes()).padStart(2, "0"); - const seconds = String(date.getSeconds()).padStart(2, "0"); - const timezone = date - .toLocaleTimeString("en-US", { timeZoneName: "short" }) - .split(" ")[1]; - const timeFormatted = `${hours}:${minutes}:${seconds} ${timezone}`; - - return `${dateFormatted}${timeFormatted}`; - } catch (error) { - console.error("Error formatting capture date:", error); - return "N/A"; - } - } - - formatDateSimple(dateString) { - try { - const date = new Date(dateString); - return date.toString() !== "Invalid Date" - ? date.toLocaleDateString("en-US", { - month: "2-digit", - day: "2-digit", - year: "numeric", - }) - : ""; - } catch (_e) { - return ""; - } - } - - /** - * Unified user-visible messaging (server-rendered HTML). - * @param {string} message - * @param {object} [opts] - * @param {'success'|'error'|'warning'|'info'|'danger'} [opts.variant='info'] - danger maps like Bootstrap - * @param {'toast'|'replace'|'append'} [opts.placement='toast'] - * @param {Element|string|null} [opts.target] - for replace/append (selector or element) - * @param {'toast'|'inline'|'alert'|'list'|'table'|'visualization_panel'} [opts.presentation='toast'] - must match template branches - * @param {object} [opts.templateContext] - extra keys passed to Django (error_list, colspan, icon, …) - * @param {Error|null} [opts.error] - error object to log - * @param {Element|string|null} [opts.triggeredBy] - element to log error for - * @param {boolean} [opts.log] - log error to console - * @param {boolean} [opts.autoRemove] - for ephemeral modal alerts (timeout ms in opts.autoRemoveMs) - * @param {number} [opts.autoRemoveMs] - timeout ms for auto removal of ephemeral modal alerts - */ - async showMessage(message, opts = {}) { - try { - const { - variant = "info", - placement = "toast", - target = null, - presentation = placement === "toast" ? "toast" : "alert", - templateContext = {}, - error = null, - triggeredBy = null, - log = false, - autoRemove = false, - autoRemoveMs = 4000, - } = opts; - - const type = - variant === "danger" || variant === "error" ? "error" : variant; - - if (log && error) { - this.logError(error, triggeredBy); - } - - const context = { - message: message ?? "", - type, - presentation, // toast | inline | alert | list | table - ...templateContext, - }; - - const response = await window.APIClient.post( - "/users/render-html/", - { - template: "users/components/message.html", - context, - }, - null, - true, - ); - - if (!response?.html) { - console.error("showMessage: no HTML from render-html"); - return false; - } - - const wrap = document.createElement("div"); - wrap.innerHTML = response.html; - const node = wrap.firstElementChild; - if (!node) { - console.error("showMessage: failed to parse message HTML"); - return false; - } - - if (placement === "toast") { - const toastHost = document.getElementById("toast-container"); - const BS = window.bootstrap; - if (!toastHost || !BS?.Toast) { - console.error( - "showMessage: toast container or Bootstrap Toast not available", - ); - return false; - } - node.id = - node.id || - `toast-${Date.now()}-${Math.random().toString(16).slice(2)}`; - toastHost.appendChild(node); - const t = new BS.Toast(node); - t.show(); - node.addEventListener("hidden.bs.toast", () => node.remove()); - return true; - } - - const el = - typeof target === "string" ? document.querySelector(target) : target; - if (!el) { - console.warn("showMessage: target not found:", target); - return false; - } - - if (placement === "append") { - el.insertBefore(node, el.firstChild); - } else if (placement === "replace") { - el.innerHTML = ""; - el.appendChild(node); - } - - if (autoRemove && presentation === "alert") { - setTimeout(() => node.remove(), autoRemoveMs); - } - return true; - } catch (err) { - console.error("showMessage failed:", err); - return false; - } - } - - /** - * Server-rendered message in #visualizationErrorDisplay (or custom target). - * @param {string} message - * @param {string|null} [detailLine] - * @param {object} [opts] - * @param {Element|string} [opts.target='#visualizationErrorDisplay'] - * @param {'success'|'error'|'warning'|'info'|'danger'} [opts.variant='info'] - * @param {() => void} [opts.beforeShow] - */ - async showVisualizationPanel(message, detailLine = null, opts = {}) { - const { - target = "#visualizationErrorDisplay", - variant = "info", - beforeShow, - } = opts; - - const el = - typeof target === "string" ? document.querySelector(target) : target; - if (!el) { - console.warn("showVisualizationPanel: target not found:", target); - return false; - } - - beforeShow?.(); - - const ok = await this.showMessage(message ?? "", { - variant, - placement: "replace", - target: el, - presentation: "visualization_panel", - templateContext: { detail_line: detailLine || "" }, - }); - - if (ok) { - el.classList.remove("d-none"); - } - return ok; - } - - /** - * @param {Element|string} [target='#visualizationErrorDisplay'] - */ - hideVisualizationPanel(target = "#visualizationErrorDisplay") { - const el = - typeof target === "string" ? document.querySelector(target) : target; - if (el) { - el.classList.add("d-none"); - } - } - - logError(error, triggeredBy = null) { - console.error( - triggeredBy ? triggeredBy : "", - this.getUserFriendlyErrorMessage(error), - ); - } - - getUserFriendlyErrorMessage(error, context = "") { - if (!error) return "An unexpected error occurred"; - - if (error.name === "TypeError" && error.message.includes("Cannot read")) { - return "Configuration error: Some components are not properly loaded"; - } - if (error.name === "TypeError" && error.message.includes("JSON")) { - return "Invalid response format: Please try again or contact support"; - } - if (error.name === "ReferenceError") { - return "Component error: Required functionality is not available"; - } - if (error.name === "NetworkError" || error.message.includes("fetch")) { - return "Network error: Please check your connection and try again"; - } - if (error.message.includes("403") || error.message.includes("Forbidden")) { - return "Access denied: You don't have permission to perform this action"; - } - if (error.message.includes("404") || error.message.includes("Not Found")) { - const fileContext = - context === "upload-handler" || - context === "file-preview" || - String(context).includes("upload"); - return fileContext - ? "Resource not found: The requested file or directory may have been moved or deleted" - : "Resource not found: The requested asset may have been moved or deleted"; - } - if ( - error.message.includes("500") || - error.message.includes("Internal Server Error") - ) { - return "Server error: Please try again later or contact support"; - } - - return error.message || "An unexpected error occurred"; - } - - /** - * Bootstrap icon action menus: dispose/recreate instances; global listeners once. - * @param {ParentNode} [root] - */ - initIconDropdowns(root = document) { - if (typeof bootstrap === "undefined" || !bootstrap.Dropdown) { - return; - } - - if (!this._iconDropdownShowDelegated) { - this._iconDropdownShowDelegated = true; - document.addEventListener("show.bs.dropdown", (e) => { - const toggle = e.target?.closest?.(".btn-icon-dropdown"); - if (!toggle) return; - const dropdownMenu = toggle.nextElementSibling; - if (dropdownMenu?.classList.contains("dropdown-menu")) { - document.body.appendChild(dropdownMenu); - } - }); - } - - if (!this._dropdownStopRowClickBound) { - this._dropdownStopRowClickBound = true; - document.addEventListener("click", (event) => { - if ( - event.target.closest(".dropdown") || - event.target.closest(".btn-icon-dropdown") || - event.target.closest(".dropdown-toggle") || - event.target.closest(".dropdown-menu") - ) { - event.stopPropagation(); - } - }); - } - - for (const toggle of root.querySelectorAll(".btn-icon-dropdown")) { - const existing = bootstrap.Dropdown.getInstance(toggle); - if (existing) { - existing.dispose(); - } - new bootstrap.Dropdown(toggle, { - container: "body", - boundary: "viewport", - popperConfig: { - modifiers: [ - { - name: "preventOverflow", - options: { - boundary: "viewport", - }, - }, - ], - }, - }); - } - } - - /** - * Render loading state using Django template - * @param {Element|string} container - Container element or selector - * @param {string} text - Loading message - * @param {Object} options - Additional options (format, size, color) - * @returns {Promise} Success status - */ - async renderLoading(container, text = "Loading...", options = {}) { - const el = - typeof container === "string" - ? document.querySelector(container) - : container; - if (!el) { - console.warn("Container not found for renderLoading:", container); - return false; - } - - const context = { - text: text, - format: options.format || "spinner", - size: options.size || "md", - color: options.color || "primary", - ...options, - }; - - try { - const response = await window.APIClient.post( - "/users/render-html/", - { - template: "users/components/loading.html", - context: context, - }, - null, - true, - ); // true = send as JSON - - if (response.html) { - el.innerHTML = response.html; - return true; - } - return false; - } catch (error) { - console.error("Error rendering loading template:", error); - return false; - } - } - - /** - * Render icon and/or text content using Django template - * @param {Element|string} container - Container element or selector - * @param {Object} options - Options (icon, text, color, icon_position, spacing) - * @returns {Promise} Success status - */ - async renderContent(container, options = {}) { - const el = - typeof container === "string" - ? document.querySelector(container) - : container; - if (!el) { - console.warn("Container not found for renderContent:", container); - return false; - } - - try { - const response = await window.APIClient.post( - "/users/render-html/", - { - template: "users/components/content.html", - context: options, - }, - null, - true, - ); // true = send as JSON - - if (response.html) { - el.innerHTML = response.html; - return true; - } - return false; - } catch (error) { - console.error("Error rendering content template:", error); - return false; - } - } - - /** - * Render table rows using Django template - * @param {Element|string} container - Container element or selector (tbody) - * @param {Array} rows - Array of row objects with cells - * @param {Object} options - Additional options (empty_message, colspan) - * @returns {Promise} Success status - */ - async renderTable(container, rows, options = {}) { - const el = - typeof container === "string" - ? document.querySelector(container) - : container; - if (!el) { - console.warn("Container not found for renderTable:", container); - return false; - } - - const { - template = "users/components/table_rows.html", - empty_message = "No items found", - colspan, - empty_colspan, - ...rest - } = options; - - const context = { - rows: rows, - empty_message, - empty_colspan: colspan || empty_colspan || 5, - ...rest, - }; - - try { - const response = await window.APIClient.post( - "/users/render-html/", - { - template, - context, - }, - null, - true, - ); // true = send as JSON - - if (response.html) { - el.innerHTML = response.html; - return true; - } - return false; - } catch (error) { - console.error("Error rendering table template:", error); - return false; - } - } - - /** - * Render select options using Django template - * @param {Element|string} selectElement - Select element or selector - * @param {Array} choices - Array of [value, label] tuples or objects with value/label - * @param {string} currentValue - Currently selected value - * @returns {Promise} Success status - */ - async renderSelectOptions(selectElement, choices, currentValue = null) { - const el = - typeof selectElement === "string" - ? document.querySelector(selectElement) - : selectElement; - if (!el) { - console.warn( - "Select element not found for renderSelectOptions:", - selectElement, - ); - return false; - } - - // Normalize choices to object format - const formattedChoices = choices.map((choice) => { - if (Array.isArray(choice)) { - // [value, label] tuple format - return { - value: choice[0], - label: choice[1], - selected: currentValue !== null && choice[0] === currentValue, - }; - } - // Already object format - return { - ...choice, - selected: currentValue !== null && choice.value === currentValue, - }; - }); - - try { - const response = await window.APIClient.post( - "/users/render-html/", - { - template: "users/components/select_options.html", - context: { choices: formattedChoices }, - }, - null, - true, - ); // true = send as JSON - - if (response.html) { - el.innerHTML = response.html; - return true; - } - return false; - } catch (error) { - console.error("Error rendering select options template:", error); - return false; - } - } - - /** - * Render pagination using Django template - * @param {Element|string} container - Container element or selector - * @param {Object} pagination - Pagination data - * @returns {Promise} Success status - */ - async renderPagination(container, pagination) { - const el = - typeof container === "string" - ? document.querySelector(container) - : container; - if (!el) { - console.warn("Container not found for renderPagination:", container); - return false; - } - - // Don't show pagination if only 1 page or no pages - if (!pagination || pagination.num_pages <= 1) { - el.innerHTML = ""; - return true; - } - - // Normalize pagination data for template - const startPage = Math.max(1, pagination.number - 2); - const endPage = Math.min(pagination.num_pages, pagination.number + 2); - - const pages = []; - for (let i = startPage; i <= endPage; i++) { - pages.push({ - number: i, - is_current: i === pagination.number, - }); - } - - const context = { - show: true, - has_previous: pagination.has_previous, - previous_page: pagination.number - 1, - has_next: pagination.has_next, - next_page: pagination.number + 1, - pages: pages, - }; - - try { - const response = await window.APIClient.post( - "/users/render-html/", - { - template: "users/components/pagination.html", - context: context, - }, - null, - true, - ); // true = send as JSON - - if (response.html) { - el.innerHTML = response.html; - return true; - } - return false; - } catch (error) { - console.error("Error rendering pagination template:", error); - return false; - } - } - - /** - * Render dropdown menu using Django template - * @param {Object} options - Dropdown configuration - * @returns {Promise} HTML string or null on error - */ - async renderDropdown(options = {}) { - const context = { - button_icon: options.button_icon || "three-dots-vertical", - button_class: options.button_class || "btn-sm btn-light", - button_label: options.button_label || "Actions", - items: options.items || [], - }; - - try { - const response = await window.APIClient.post( - "/users/render-html/", - { - template: "users/components/dropdown_menu.html", - context: context, - }, - null, - true, - ); // true = send as JSON - - if (response.html) { - return response.html; - } - return null; - } catch (error) { - console.error("Error rendering dropdown template:", error); - return null; - } - } + /** + * Format file size + * @param {number} bytes - File size in bytes + * @returns {string} Formatted file size + */ + formatFileSize(bytes) { + const n = Number(bytes) + if (!Number.isFinite(n) || n < 0) return "0 bytes" + if (n === 0) return "0 bytes" + const units = ["bytes", "KB", "MB", "GB"] + let i = 0 + let v = n + while (v >= 1024 && i < units.length - 1) { + v /= 1024 + i++ + } + return `${i === 0 ? v : v.toFixed(2)} ${units[i]}` + } + + /** + * Show element using CSS classes + * @param {Element|string} element - Element or selector to show + * @param {string} displayClass - CSS display class to add (default: "display-block") + */ + show(element, displayClass = "display-block") { + const el = + typeof element === "string" + ? document.querySelector(element) + : element + if (!el) { + console.warn("Element not found for show():", element) + return + } + + el.classList.remove("display-none", "d-none") + el.classList.add(displayClass) + } + + /** + * Hide element using CSS classes + * @param {Element|string} element - Element or selector to hide + * @param {string} displayClass - CSS display class to remove (default: "display-block") + */ + hide(element, displayClass = "display-block") { + const el = + typeof element === "string" + ? document.querySelector(element) + : element + if (!el) { + console.warn("Element not found for hide():", element) + return + } + + el.classList.remove(displayClass) + el.classList.add("display-none") + } + + /** + * Remove alert elements inside a container (e.g. modal body). + * @param {Element|string} target + */ + clearAlerts(target) { + const el = + typeof target === "string" ? document.querySelector(target) : target + if (!el) return + for (const alert of el.querySelectorAll(".alert")) { + alert.remove() + } + } + + formatDate(dateString) { + if (!dateString) return "
-
" + + let date + if (typeof dateString === "string") { + date = new Date(dateString) + } else { + date = new Date(dateString) + } + + if (!date || Number.isNaN(date.getTime())) { + return "
-
" + } + + const month = String(date.getMonth() + 1).padStart(2, "0") + const day = String(date.getDate()).padStart(2, "0") + const year = date.getFullYear() + const hours = date.getHours() + const minutes = String(date.getMinutes()).padStart(2, "0") + const seconds = String(date.getSeconds()).padStart(2, "0") + const ampm = hours >= 12 ? "PM" : "AM" + const displayHours = hours % 12 || 12 + + return `
${month}/${day}/${year}
${displayHours}:${minutes}:${seconds} ${ampm}` + } + + formatDateForModal(dateString) { + if (!dateString || dateString === "None") { + return "N/A" + } + + try { + const date = new Date(dateString) + if (Number.isNaN(date.getTime())) { + return "N/A" + } + + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, "0") + const day = String(date.getDate()).padStart(2, "0") + const dateFormatted = `${year}-${month}-${day}` + + const hours = String(date.getHours()).padStart(2, "0") + const minutes = String(date.getMinutes()).padStart(2, "0") + const seconds = String(date.getSeconds()).padStart(2, "0") + const timezone = date + .toLocaleTimeString("en-US", { timeZoneName: "short" }) + .split(" ")[1] + const timeFormatted = `${hours}:${minutes}:${seconds} ${timezone}` + + return `${dateFormatted}${timeFormatted}` + } catch (error) { + console.error("Error formatting capture date:", error) + return "N/A" + } + } + + formatDateSimple(dateString) { + try { + const date = new Date(dateString) + return date.toString() !== "Invalid Date" + ? date.toLocaleDateString("en-US", { + month: "2-digit", + day: "2-digit", + year: "numeric", + }) + : "" + } catch (_e) { + return "" + } + } + + /** + * Unified user-visible messaging (server-rendered HTML). + * @param {string} message + * @param {object} [opts] + * @param {'success'|'error'|'warning'|'info'|'danger'} [opts.variant='info'] - danger maps like Bootstrap + * @param {'toast'|'replace'|'append'} [opts.placement='toast'] + * @param {Element|string|null} [opts.target] - for replace/append (selector or element) + * @param {'toast'|'inline'|'alert'|'list'|'table'|'visualization_panel'} [opts.presentation='toast'] - must match template branches + * @param {object} [opts.templateContext] - extra keys passed to Django (error_list, colspan, icon, …) + * @param {Error|null} [opts.error] - error object to log + * @param {Element|string|null} [opts.triggeredBy] - element to log error for + * @param {boolean} [opts.log] - log error to console + * @param {boolean} [opts.autoRemove] - for ephemeral modal alerts (timeout ms in opts.autoRemoveMs) + * @param {number} [opts.autoRemoveMs] - timeout ms for auto removal of ephemeral modal alerts + */ + async showMessage(message, opts = {}) { + try { + const { + variant = "info", + placement = "toast", + target = null, + presentation = placement === "toast" ? "toast" : "alert", + templateContext = {}, + error = null, + triggeredBy = null, + log = false, + autoRemove = false, + autoRemoveMs = 4000, + } = opts + + const type = + variant === "danger" || variant === "error" ? "error" : variant + + if (log && error) { + this.logError(error, triggeredBy) + } + + const context = { + message: message ?? "", + type, + presentation, // toast | inline | alert | list | table + ...templateContext, + } + + const response = await window.APIClient.post( + "/users/render-html/", + { + template: "users/components/message.html", + context, + }, + null, + true, + ) + + if (!response?.html) { + console.error("showMessage: no HTML from render-html") + return false + } + + const wrap = document.createElement("div") + wrap.innerHTML = response.html + const node = wrap.firstElementChild + if (!node) { + console.error("showMessage: failed to parse message HTML") + return false + } + + if (placement === "toast") { + const toastHost = document.getElementById("toast-container") + const BS = window.bootstrap + if (!toastHost || !BS?.Toast) { + console.error( + "showMessage: toast container or Bootstrap Toast not available", + ) + return false + } + node.id = + node.id || + `toast-${Date.now()}-${Math.random().toString(16).slice(2)}` + toastHost.appendChild(node) + const t = new BS.Toast(node) + t.show() + node.addEventListener("hidden.bs.toast", () => node.remove()) + return true + } + + const el = + typeof target === "string" + ? document.querySelector(target) + : target + if (!el) { + console.warn("showMessage: target not found:", target) + return false + } + + if (placement === "append") { + el.insertBefore(node, el.firstChild) + } else if (placement === "replace") { + el.innerHTML = "" + el.appendChild(node) + } + + if (autoRemove && presentation === "alert") { + setTimeout(() => node.remove(), autoRemoveMs) + } + return true + } catch (err) { + console.error("showMessage failed:", err) + return false + } + } + + /** + * Server-rendered message in #visualizationErrorDisplay (or custom target). + * @param {string} message + * @param {string|null} [detailLine] + * @param {object} [opts] + * @param {Element|string} [opts.target='#visualizationErrorDisplay'] + * @param {'success'|'error'|'warning'|'info'|'danger'} [opts.variant='info'] + * @param {() => void} [opts.beforeShow] + */ + async showVisualizationPanel(message, detailLine = null, opts = {}) { + const { + target = "#visualizationErrorDisplay", + variant = "info", + beforeShow, + } = opts + + const el = + typeof target === "string" ? document.querySelector(target) : target + if (!el) { + console.warn("showVisualizationPanel: target not found:", target) + return false + } + + beforeShow?.() + + const ok = await this.showMessage(message ?? "", { + variant, + placement: "replace", + target: el, + presentation: "visualization_panel", + templateContext: { detail_line: detailLine || "" }, + }) + + if (ok) { + el.classList.remove("d-none") + } + return ok + } + + /** + * @param {Element|string} [target='#visualizationErrorDisplay'] + */ + hideVisualizationPanel(target = "#visualizationErrorDisplay") { + const el = + typeof target === "string" ? document.querySelector(target) : target + if (el) { + el.classList.add("d-none") + } + } + + logError(error, triggeredBy = null) { + console.error( + triggeredBy ? triggeredBy : "", + this.getUserFriendlyErrorMessage(error), + ) + } + + getUserFriendlyErrorMessage(error, context = "") { + if (!error) return "An unexpected error occurred" + + if ( + error.name === "TypeError" && + error.message.includes("Cannot read") + ) { + return "Configuration error: Some components are not properly loaded" + } + if (error.name === "TypeError" && error.message.includes("JSON")) { + return "Invalid response format: Please try again or contact support" + } + if (error.name === "ReferenceError") { + return "Component error: Required functionality is not available" + } + if (error.name === "NetworkError" || error.message.includes("fetch")) { + return "Network error: Please check your connection and try again" + } + if ( + error.message.includes("403") || + error.message.includes("Forbidden") + ) { + return "Access denied: You don't have permission to perform this action" + } + if ( + error.message.includes("404") || + error.message.includes("Not Found") + ) { + const fileContext = + context === "upload-handler" || + context === "file-preview" || + String(context).includes("upload") + return fileContext + ? "Resource not found: The requested file or directory may have been moved or deleted" + : "Resource not found: The requested asset may have been moved or deleted" + } + if ( + error.message.includes("500") || + error.message.includes("Internal Server Error") + ) { + return "Server error: Please try again later or contact support" + } + + return error.message || "An unexpected error occurred" + } + + /** + * Bootstrap icon action menus: dispose/recreate instances; global listeners once. + * @param {ParentNode} [root] + */ + initIconDropdowns(root = document) { + if (typeof bootstrap === "undefined" || !bootstrap.Dropdown) { + return + } + + if (!this._iconDropdownShowDelegated) { + this._iconDropdownShowDelegated = true + document.addEventListener("show.bs.dropdown", (e) => { + const toggle = e.target?.closest?.(".btn-icon-dropdown") + if (!toggle) return + const dropdownMenu = toggle.nextElementSibling + if (dropdownMenu?.classList.contains("dropdown-menu")) { + document.body.appendChild(dropdownMenu) + } + }) + } + + if (!this._dropdownStopRowClickBound) { + this._dropdownStopRowClickBound = true + document.addEventListener("click", (event) => { + if ( + event.target.closest(".dropdown") || + event.target.closest(".btn-icon-dropdown") || + event.target.closest(".dropdown-toggle") || + event.target.closest(".dropdown-menu") + ) { + event.stopPropagation() + } + }) + } + + for (const toggle of root.querySelectorAll(".btn-icon-dropdown")) { + const existing = bootstrap.Dropdown.getInstance(toggle) + if (existing) { + existing.dispose() + } + new bootstrap.Dropdown(toggle, { + container: "body", + boundary: "viewport", + popperConfig: { + modifiers: [ + { + name: "preventOverflow", + options: { + boundary: "viewport", + }, + }, + ], + }, + }) + } + } + + /** + * Render loading state using Django template + * @param {Element|string} container - Container element or selector + * @param {string} text - Loading message + * @param {Object} options - Additional options (format, size, color) + * @returns {Promise} Success status + */ + async renderLoading(container, text = "Loading...", options = {}) { + const el = + typeof container === "string" + ? document.querySelector(container) + : container + if (!el) { + console.warn("Container not found for renderLoading:", container) + return false + } + + const context = { + text: text, + format: options.format || "spinner", + size: options.size || "md", + color: options.color || "primary", + ...options, + } + + try { + const response = await window.APIClient.post( + "/users/render-html/", + { + template: "users/components/loading.html", + context: context, + }, + null, + true, + ) // true = send as JSON + + if (response.html) { + el.innerHTML = response.html + return true + } + return false + } catch (error) { + console.error("Error rendering loading template:", error) + return false + } + } + + /** + * Render icon and/or text content using Django template + * @param {Element|string} container - Container element or selector + * @param {Object} options - Options (icon, text, color, icon_position, spacing) + * @returns {Promise} Success status + */ + async renderContent(container, options = {}) { + const el = + typeof container === "string" + ? document.querySelector(container) + : container + if (!el) { + console.warn("Container not found for renderContent:", container) + return false + } + + try { + const response = await window.APIClient.post( + "/users/render-html/", + { + template: "users/components/content.html", + context: options, + }, + null, + true, + ) // true = send as JSON + + if (response.html) { + el.innerHTML = response.html + return true + } + return false + } catch (error) { + console.error("Error rendering content template:", error) + return false + } + } + + /** + * Render table rows using Django template + * @param {Element|string} container - Container element or selector (tbody) + * @param {Array} rows - Array of row objects with cells + * @param {Object} options - Additional options (empty_message, colspan) + * @returns {Promise} Success status + */ + async renderTable(container, rows, options = {}) { + const el = + typeof container === "string" + ? document.querySelector(container) + : container + if (!el) { + console.warn("Container not found for renderTable:", container) + return false + } + + const { + template = "users/components/table_rows.html", + empty_message = "No items found", + colspan, + empty_colspan, + ...rest + } = options + + const context = { + rows: rows, + empty_message, + empty_colspan: colspan || empty_colspan || 5, + ...rest, + } + + try { + const response = await window.APIClient.post( + "/users/render-html/", + { + template, + context, + }, + null, + true, + ) // true = send as JSON + + if (response.html) { + el.innerHTML = response.html + return true + } + return false + } catch (error) { + console.error("Error rendering table template:", error) + return false + } + } + + /** + * Render select options using Django template + * @param {Element|string} selectElement - Select element or selector + * @param {Array} choices - Array of [value, label] tuples or objects with value/label + * @param {string} currentValue - Currently selected value + * @returns {Promise} Success status + */ + async renderSelectOptions(selectElement, choices, currentValue = null) { + const el = + typeof selectElement === "string" + ? document.querySelector(selectElement) + : selectElement + if (!el) { + console.warn( + "Select element not found for renderSelectOptions:", + selectElement, + ) + return false + } + + // Normalize choices to object format + const formattedChoices = choices.map((choice) => { + if (Array.isArray(choice)) { + // [value, label] tuple format + return { + value: choice[0], + label: choice[1], + selected: + currentValue !== null && choice[0] === currentValue, + } + } + // Already object format + return { + ...choice, + selected: + currentValue !== null && choice.value === currentValue, + } + }) + + try { + const response = await window.APIClient.post( + "/users/render-html/", + { + template: "users/components/select_options.html", + context: { choices: formattedChoices }, + }, + null, + true, + ) // true = send as JSON + + if (response.html) { + el.innerHTML = response.html + return true + } + return false + } catch (error) { + console.error("Error rendering select options template:", error) + return false + } + } + + /** + * Render pagination using Django template + * @param {Element|string} container - Container element or selector + * @param {Object} pagination - Pagination data + * @returns {Promise} Success status + */ + async renderPagination(container, pagination) { + const el = + typeof container === "string" + ? document.querySelector(container) + : container + if (!el) { + console.warn("Container not found for renderPagination:", container) + return false + } + + // Don't show pagination if only 1 page or no pages + if (!pagination || pagination.num_pages <= 1) { + el.innerHTML = "" + return true + } + + // Normalize pagination data for template + const startPage = Math.max(1, pagination.number - 2) + const endPage = Math.min(pagination.num_pages, pagination.number + 2) + + const pages = [] + for (let i = startPage; i <= endPage; i++) { + pages.push({ + number: i, + is_current: i === pagination.number, + }) + } + + const context = { + show: true, + has_previous: pagination.has_previous, + previous_page: pagination.number - 1, + has_next: pagination.has_next, + next_page: pagination.number + 1, + pages: pages, + } + + try { + const response = await window.APIClient.post( + "/users/render-html/", + { + template: "users/components/pagination.html", + context: context, + }, + null, + true, + ) // true = send as JSON + + if (response.html) { + el.innerHTML = response.html + return true + } + return false + } catch (error) { + console.error("Error rendering pagination template:", error) + return false + } + } + + /** + * Render dropdown menu using Django template + * @param {Object} options - Dropdown configuration + * @returns {Promise} HTML string or null on error + */ + async renderDropdown(options = {}) { + const context = { + button_icon: options.button_icon || "three-dots-vertical", + button_class: options.button_class || "btn-sm btn-light", + button_label: options.button_label || "Actions", + items: options.items || [], + } + + try { + const response = await window.APIClient.post( + "/users/render-html/", + { + template: "users/components/dropdown_menu.html", + context: context, + }, + null, + true, + ) // true = send as JSON + + if (response.html) { + return response.html + } + return null + } catch (error) { + console.error("Error rendering dropdown template:", error) + return null + } + } } // Create global instance -window.DOMUtils = new DOMUtils(); +window.DOMUtils = new DOMUtils() -window.showMessage = window.DOMUtils.showMessage.bind(window.DOMUtils); +window.showMessage = window.DOMUtils.showMessage.bind(window.DOMUtils) // Export for ES6 modules (Jest testing) - only if in module context if (typeof module !== "undefined" && module.exports) { - module.exports = { DOMUtils }; + module.exports = { DOMUtils } } diff --git a/gateway/sds_gateway/static/js/core/ModalManager.js b/gateway/sds_gateway/static/js/core/ModalManager.js index 6f5452e19..684953183 100644 --- a/gateway/sds_gateway/static/js/core/ModalManager.js +++ b/gateway/sds_gateway/static/js/core/ModalManager.js @@ -12,312 +12,333 @@ */ class ModalManager extends BaseManager { - constructor(config = {}) { - super(); - this.modalId = config.modalId; - if (config.modalId) { - this.modal = document.getElementById(this.modalId); - this.modalTitle = config.modalTitleId - ? document.getElementById(config.modalTitleId) - : this.modal?.querySelector(".modal-title"); - this.modalBody = config.modalBodyId - ? document.getElementById(config.modalBodyId) - : this.modal?.querySelector(".modal-body"); - } - } + constructor(config = {}) { + super() + this.modalId = config.modalId + if (config.modalId) { + this.modal = document.getElementById(this.modalId) + this.modalTitle = config.modalTitleId + ? document.getElementById(config.modalTitleId) + : this.modal?.querySelector(".modal-title") + this.modalBody = config.modalBodyId + ? document.getElementById(config.modalBodyId) + : this.modal?.querySelector(".modal-body") + } + } - /** - * @param {string | HTMLElement} idOrElement - */ - /** - * @param {string} modalId - * @param {{ trigger?: HTMLElement|null }} [options] - */ - openModal(modalId, options = {}) { - const element = document.getElementById(modalId); - if (!element) return null; - const onShown = () => { - ModalManager._onModalShown(element, options); - }; - element.addEventListener("shown.bs.modal", onShown, { once: true }); - const inst = ModalManager.getOrCreateBootstrapModal(element, { - backdrop: true, - keyboard: true, - focus: true, - }); - if (inst) inst.show(); - return inst; - } + /** + * @param {string | HTMLElement} idOrElement + */ + /** + * @param {string} modalId + * @param {{ trigger?: HTMLElement|null }} [options] + */ + openModal(modalId, options = {}) { + const element = document.getElementById(modalId) + if (!element) return null + const onShown = () => { + ModalManager._onModalShown(element, options) + } + element.addEventListener("shown.bs.modal", onShown, { once: true }) + const inst = ModalManager.getOrCreateBootstrapModal(element, { + backdrop: true, + keyboard: true, + focus: true, + }) + if (inst) inst.show() + return inst + } - /** - * @param {HTMLElement} modal - * @param {{ trigger?: HTMLElement|null }} [options] - */ - static _onModalShown(modal, options = {}) { - const downloadManager = - options.downloadActionManager ?? window.downloadActionManager; - if ( - modal?.id?.startsWith("webDownloadModal-") && - downloadManager?.prepareWebDownloadModal - ) { - downloadManager.prepareWebDownloadModal(modal, options.trigger ?? null); - } - } + /** + * @param {HTMLElement} modal + * @param {{ trigger?: HTMLElement|null }} [options] + */ + static _onModalShown(modal, options = {}) { + const downloadManager = + options.downloadActionManager ?? window.downloadActionManager + if ( + modal?.id?.startsWith("webDownloadModal-") && + downloadManager?.prepareWebDownloadModal + ) { + downloadManager.prepareWebDownloadModal( + modal, + options.trigger ?? null, + ) + } + } - /** - * @param {string | HTMLElement} idOrElement - */ - closeModal(modalId) { - const element = document.getElementById(modalId); - if (!element || !window.bootstrap?.Modal) return; - const inst = bootstrap.Modal.getInstance(element); - if (inst) inst.hide(); - } + /** + * @param {string | HTMLElement} idOrElement + */ + closeModal(modalId) { + const element = document.getElementById(modalId) + if (!element || !window.bootstrap?.Modal) return + const inst = bootstrap.Modal.getInstance(element) + if (inst) inst.hide() + } - /** - * @param {object} config - * @param {ParentNode} [config.root] - * @param {boolean} [config.bootstrap=true] - * @param {'dataset'|'capture'|'both'} [config.wireListModals] - * @param {object} [config.permissions] - raw perm config for PermissionsManager - * @param {unknown[]} [config.managersOut] - * @param {boolean} [config.detailsClickDelegation] - * @param {boolean} [config.wireAllDataItemModalsShare] - * @param {boolean} [config.registerFilesCaptureCoordinator] - * @param {object} [config.downloadActionManager] - * @returns {() => void} cleanup - */ - static initializeModal(config = {}) { - const cleanups = []; - const root = config.root ?? document; + /** + * @param {object} config + * @param {ParentNode} [config.root] + * @param {boolean} [config.bootstrap=true] + * @param {'dataset'|'capture'|'both'} [config.wireListModals] + * @param {object} [config.permissions] - raw perm config for PermissionsManager + * @param {unknown[]} [config.managersOut] + * @param {boolean} [config.detailsClickDelegation] + * @param {boolean} [config.wireAllDataItemModalsShare] + * @param {boolean} [config.registerFilesCaptureCoordinator] + * @param {object} [config.downloadActionManager] + * @returns {() => void} cleanup + */ + static initializeModal(config = {}) { + const cleanups = [] + const root = config.root ?? document - if (config.bootstrap !== false) { - ModalManager.prepareBootstrapModalInstances(root); - } + if (config.bootstrap !== false) { + ModalManager.prepareBootstrapModalInstances(root) + } - if (config.registerFilesCaptureCoordinator) { - window.filesCaptureModalManager = new ModalManager({ - modalId: "asset-details-modal", - modalBodyId: "asset-details-modal-body", - modalTitleId: "asset-details-modal-label", - }); - } + if (config.registerFilesCaptureCoordinator) { + window.filesCaptureModalManager = new ModalManager({ + modalId: "asset-details-modal", + modalBodyId: "asset-details-modal-body", + modalTitleId: "asset-details-modal-label", + }) + } - if (config.detailsClickDelegation) { - const detach = - window.AssetDetailsModalLoader?.ensureDetailsClickDelegation?.() ?? - (() => {}); - cleanups.push(detach); - } + if (config.detailsClickDelegation) { + const detach = + window.AssetDetailsModalLoader?.ensureDetailsClickDelegation?.() ?? + (() => {}) + cleanups.push(detach) + } - const managersOut = config.managersOut; - const permissions = config.permissions; - if (permissions && managersOut && window.PermissionsManager) { - const permMgr = - permissions instanceof window.PermissionsManager - ? permissions - : new window.PermissionsManager(permissions); - const wire = config.wireListModals; - if (wire === "dataset" || wire === "both") { - ModalManager._wireDatasetListModalsInternal(permMgr, managersOut); - } - if (wire === "capture" || wire === "both") { - ModalManager._wireCaptureListModalsInternal(permMgr, managersOut); - } - } + const managersOut = config.managersOut + const permissions = config.permissions + if (permissions && managersOut && window.PermissionsManager) { + const permMgr = + permissions instanceof window.PermissionsManager + ? permissions + : new window.PermissionsManager(permissions) + const wire = config.wireListModals + if (wire === "dataset" || wire === "both") { + ModalManager._wireDatasetListModalsInternal( + permMgr, + managersOut, + ) + } + if (wire === "capture" || wire === "both") { + ModalManager._wireCaptureListModalsInternal( + permMgr, + managersOut, + ) + } + } - if ( - config.wireAllDataItemModalsShare && - permissions && - window.PermissionsManager && - window.ShareActionManager - ) { - const permissionsManager = new window.PermissionsManager(permissions); - const uuidRegex = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - const validTypes = new Set(["capture", "dataset", "file"]); - const bound = []; + if ( + config.wireAllDataItemModalsShare && + permissions && + window.PermissionsManager && + window.ShareActionManager + ) { + const permissionsManager = new window.PermissionsManager( + permissions, + ) + const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + const validTypes = new Set(["capture", "dataset", "file"]) + const bound = [] - for (const modal of document.querySelectorAll(".modal[data-item-uuid]")) { - const itemUuid = modal.getAttribute("data-item-uuid"); - const itemType = modal.getAttribute("data-item-type"); - if ( - !itemUuid || - !itemType || - !uuidRegex.test(itemUuid) || - !validTypes.has(itemType) - ) { - continue; - } + for (const modal of document.querySelectorAll( + ".modal[data-item-uuid]", + )) { + const itemUuid = modal.getAttribute("data-item-uuid") + const itemType = modal.getAttribute("data-item-type") + if ( + !itemUuid || + !itemType || + !uuidRegex.test(itemUuid) || + !validTypes.has(itemType) + ) { + continue + } - const shareManager = new window.ShareActionManager({ - itemUuid, - itemType, - permissions: permissionsManager, - }); - modal.shareActionManager = shareManager; + const shareManager = new window.ShareActionManager({ + itemUuid, + itemType, + permissions: permissionsManager, + }) + modal.shareActionManager = shareManager - const onHidden = () => { - modal.shareActionManager?.clearSelections?.(); - }; - modal.addEventListener("hidden.bs.modal", onHidden); - bound.push({ modal, onHidden }); - } + const onHidden = () => { + modal.shareActionManager?.clearSelections?.() + } + modal.addEventListener("hidden.bs.modal", onHidden) + bound.push({ modal, onHidden }) + } - cleanups.push(() => { - for (const { modal, onHidden } of bound) { - modal.removeEventListener("hidden.bs.modal", onHidden); - } - }); - } + cleanups.push(() => { + for (const { modal, onHidden } of bound) { + modal.removeEventListener("hidden.bs.modal", onHidden) + } + }) + } - if (config.downloadActionManager) { - cleanups.push( - ModalManager._wireWebDownloadModalTriggers( - root, - config.downloadActionManager, - ), - ); - } + if (config.downloadActionManager) { + cleanups.push( + ModalManager._wireWebDownloadModalTriggers( + root, + config.downloadActionManager, + ), + ) + } - return () => { - for (const fn of cleanups) { - fn?.(); - } - }; - } + return () => { + for (const fn of cleanups) { + fn?.() + } + } + } - /** - * @param {string} modalId - * @param {string} [text] - */ - static async showModalLoading(modalId, text = "Loading...") { - const modal = document.getElementById(modalId); - if (!modal) return; - const modalBody = modal.querySelector(".modal-body"); - if (modalBody && window.DOMUtils?.renderLoading) { - await window.DOMUtils.renderLoading(modalBody, text, { - format: "modal", - }); - } - } + /** + * @param {string} modalId + * @param {string} [text] + */ + static async showModalLoading(modalId, text = "Loading...") { + const modal = document.getElementById(modalId) + if (!modal) return + const modalBody = modal.querySelector(".modal-body") + if (modalBody && window.DOMUtils?.renderLoading) { + await window.DOMUtils.renderLoading(modalBody, text, { + format: "modal", + }) + } + } - static prepareBootstrapModalInstances(root = document) { - if (!window.bootstrap) return; - for (const modal of root.querySelectorAll(".modal")) { - const existingInstance = bootstrap.Modal.getInstance(modal); - if (existingInstance) { - try { - existingInstance.dispose(); - } catch (e) { - console.warn("Failed to dispose modal instance:", e); - } - } - new bootstrap.Modal(modal, { - backdrop: true, - keyboard: true, - focus: true, - }); - } - } + static prepareBootstrapModalInstances(root = document) { + if (!window.bootstrap) return + for (const modal of root.querySelectorAll(".modal")) { + const existingInstance = bootstrap.Modal.getInstance(modal) + if (existingInstance) { + try { + existingInstance.dispose() + } catch (e) { + console.warn("Failed to dispose modal instance:", e) + } + } + new bootstrap.Modal(modal, { + backdrop: true, + keyboard: true, + focus: true, + }) + } + } - static getOrCreateBootstrapModal(element, options = {}) { - if (!element || !window.bootstrap?.Modal) return null; - let inst = bootstrap.Modal.getInstance(element); - if (!inst) inst = new bootstrap.Modal(element, options); - return inst; - } + static getOrCreateBootstrapModal(element, options = {}) { + if (!element || !window.bootstrap?.Modal) return null + let inst = bootstrap.Modal.getInstance(element) + if (!inst) inst = new bootstrap.Modal(element, options) + return inst + } - static _wireShareManagerForListModal(modal, permissions, managersOut, contextLabel) { - const itemUuid = modal.getAttribute("data-item-uuid"); - const itemType = modal.getAttribute("data-item-type"); + static _wireShareManagerForListModal( + modal, + permissions, + managersOut, + contextLabel, + ) { + const itemUuid = modal.getAttribute("data-item-uuid") + const itemType = modal.getAttribute("data-item-type") - if (!itemUuid || !permissions) { - console.warn( - `No item UUID or permissions found for ${contextLabel}: ${modal}`, - ); - return null; - } + if (!itemUuid || !permissions) { + console.warn( + `No item UUID or permissions found for ${contextLabel}: ${modal}`, + ) + return null + } - if (window.ShareActionManager) { - const shareManager = new window.ShareActionManager({ - permissions, - itemUuid: itemUuid, - itemType: itemType, - }); - managersOut.push(shareManager); - modal.shareActionManager = shareManager; - } - return itemUuid; - } + if (window.ShareActionManager) { + const shareManager = new window.ShareActionManager({ + permissions, + itemUuid: itemUuid, + itemType: itemType, + }) + managersOut.push(shareManager) + modal.shareActionManager = shareManager + } + return itemUuid + } - static _wireDatasetListModalsInternal(permissions, managersOut) { - const datasetModals = document.querySelectorAll( - ".modal[data-item-type='dataset']", - ); + static _wireDatasetListModalsInternal(permissions, managersOut) { + const datasetModals = document.querySelectorAll( + ".modal[data-item-type='dataset']", + ) - for (const modal of datasetModals) { - const itemUuid = ModalManager._wireShareManagerForListModal( - modal, - permissions, - managersOut, - "dataset modal", - ); - if (!itemUuid) continue; + for (const modal of datasetModals) { + const itemUuid = ModalManager._wireShareManagerForListModal( + modal, + permissions, + managersOut, + "dataset modal", + ) + if (!itemUuid) continue - if (window.VersioningActionManager && !modal.versioningActionManager) { - const versioningManager = new window.VersioningActionManager({ - permissions, - datasetUuid: itemUuid, - }); - managersOut.push(versioningManager); - modal.versioningActionManager = versioningManager; - } - } - } + if ( + window.VersioningActionManager && + !modal.versioningActionManager + ) { + const versioningManager = new window.VersioningActionManager({ + permissions, + datasetUuid: itemUuid, + }) + managersOut.push(versioningManager) + modal.versioningActionManager = versioningManager + } + } + } - static _wireCaptureListModalsInternal(permissions, managersOut) { - const captureModals = document.querySelectorAll( - ".modal[data-item-type='capture']", - ); + static _wireCaptureListModalsInternal(permissions, managersOut) { + const captureModals = document.querySelectorAll( + ".modal[data-item-type='capture']", + ) - for (const modal of captureModals) { - ModalManager._wireShareManagerForListModal( - modal, - permissions, - managersOut, - "capture modal", - ); - } - } + for (const modal of captureModals) { + ModalManager._wireShareManagerForListModal( + modal, + permissions, + managersOut, + "capture modal", + ) + } + } - /** - * Route list-page download menu items through openModal (not raw data-bs-toggle). - * @param {ParentNode} root - * @param {object} downloadActionManager - * @returns {() => void} - */ - static _wireWebDownloadModalTriggers(root, downloadActionManager) { - const handler = (event) => { - const toggle = event.target.closest( - '[data-bs-toggle="modal"][data-bs-target^="#webDownloadModal"],' + - '[data-bs-toggle="modal"][href^="#webDownloadModal"]', - ); - if (!toggle || !root.contains(toggle)) { - return; - } - event.preventDefault(); - event.stopPropagation(); - downloadActionManager.openWebDownloadFromToggle(toggle); - }; - root.addEventListener("click", handler, true); - return () => root.removeEventListener("click", handler, true); - } + /** + * Route list-page download menu items through openModal (not raw data-bs-toggle). + * @param {ParentNode} root + * @param {object} downloadActionManager + * @returns {() => void} + */ + static _wireWebDownloadModalTriggers(root, downloadActionManager) { + const handler = (event) => { + const toggle = event.target.closest( + '[data-bs-toggle="modal"][data-bs-target^="#webDownloadModal"],' + + '[data-bs-toggle="modal"][href^="#webDownloadModal"]', + ) + if (!toggle || !root.contains(toggle)) { + return + } + event.preventDefault() + event.stopPropagation() + downloadActionManager.openWebDownloadFromToggle(toggle) + } + root.addEventListener("click", handler, true) + return () => root.removeEventListener("click", handler, true) + } } if (typeof window !== "undefined") { - window.ModalManager = ModalManager; + window.ModalManager = ModalManager } if (typeof module !== "undefined" && module.exports) { - module.exports = { ModalManager }; + module.exports = { ModalManager } } diff --git a/gateway/sds_gateway/static/js/core/PageController.js b/gateway/sds_gateway/static/js/core/PageController.js index 8b7dc676f..f75d6acf1 100644 --- a/gateway/sds_gateway/static/js/core/PageController.js +++ b/gateway/sds_gateway/static/js/core/PageController.js @@ -3,100 +3,103 @@ * Intended for per-page controllers (captures/files/datasets) to extend. */ class PageController { - constructor() { - this._bindings = []; - this._initialized = false; - } + constructor() { + this._bindings = [] + this._initialized = false + } - /** - * Bind an event listener and track it for cleanup. - * @param {EventTarget|null} target - * @param {string} eventName - * @param {Function} handler - * @param {boolean|AddEventListenerOptions} [options] - */ - bind(target, eventName, handler, options) { - if (!target?.addEventListener) return; - target.addEventListener(eventName, handler, options); - this._bindings.push({ target, eventName, handler, options }); - } + /** + * Bind an event listener and track it for cleanup. + * @param {EventTarget|null} target + * @param {string} eventName + * @param {Function} handler + * @param {boolean|AddEventListenerOptions} [options] + */ + bind(target, eventName, handler, options) { + if (!target?.addEventListener) return + target.addEventListener(eventName, handler, options) + this._bindings.push({ target, eventName, handler, options }) + } - /** - * Remove all tracked listeners. - */ - unbindAll() { - for (const b of this._bindings) { - try { - b.target?.removeEventListener?.(b.eventName, b.handler, b.options); - } catch (_) {} - } - this._bindings = []; - } + /** + * Remove all tracked listeners. + */ + unbindAll() { + for (const b of this._bindings) { + try { + b.target?.removeEventListener?.( + b.eventName, + b.handler, + b.options, + ) + } catch (_) {} + } + this._bindings = [] + } - /** - * Initialize controller once. Subclasses should override the individual hooks. - */ - init() { - if (this._initialized) return; - this._initialized = true; + /** + * Initialize controller once. Subclasses should override the individual hooks. + */ + init() { + if (this._initialized) return + this._initialized = true - this.cacheElements(); - this.initializeComponents(); - this.initializeEventHandlers(); - this.initializeFromURL(); - } + this.cacheElements() + this.initializeComponents() + this.initializeEventHandlers() + this.initializeFromURL() + } - /** - * Shared list-page bootstrap (dataset list, capture list, etc.). - * @param {{ pageLifecycleConfig?: object, listRefreshConfig?: object }} opts - */ - static initListPage(opts = {}) { - if (typeof window === "undefined") return; - const { pageLifecycleConfig, listRefreshConfig } = opts; - if (pageLifecycleConfig && window.PageLifecycleManager) { - window.pageLifecycleManager = new window.PageLifecycleManager( - pageLifecycleConfig, - ); - } - if (listRefreshConfig && window.ListRefreshManager) { - window.listRefreshManager = new window.ListRefreshManager( - listRefreshConfig, - ); - } - PageController._initListIconDropdowns(listRefreshConfig); - } + /** + * Shared list-page bootstrap (dataset list, capture list, etc.). + * @param {{ pageLifecycleConfig?: object, listRefreshConfig?: object }} opts + */ + static initListPage(opts = {}) { + if (typeof window === "undefined") return + const { pageLifecycleConfig, listRefreshConfig } = opts + if (pageLifecycleConfig && window.PageLifecycleManager) { + window.pageLifecycleManager = new window.PageLifecycleManager( + pageLifecycleConfig, + ) + } + if (listRefreshConfig && window.ListRefreshManager) { + window.listRefreshManager = new window.ListRefreshManager( + listRefreshConfig, + ) + } + PageController._initListIconDropdowns(listRefreshConfig) + } - /** - * Bootstrap icon dropdowns in list table markup (SSR + after AJAX refresh). - * @param {{ containerSelector?: string }|undefined} listRefreshConfig - */ - static _initListIconDropdowns(listRefreshConfig) { - if (!window.DOMUtils?.initIconDropdowns) return; - const root = listRefreshConfig?.containerSelector - ? document.querySelector(listRefreshConfig.containerSelector) || - document - : document; - window.DOMUtils.initIconDropdowns(root); - } + /** + * Bootstrap icon dropdowns in list table markup (SSR + after AJAX refresh). + * @param {{ containerSelector?: string }|undefined} listRefreshConfig + */ + static _initListIconDropdowns(listRefreshConfig) { + if (!window.DOMUtils?.initIconDropdowns) return + const root = listRefreshConfig?.containerSelector + ? document.querySelector(listRefreshConfig.containerSelector) || + document + : document + window.DOMUtils.initIconDropdowns(root) + } - // Hooks (override in subclasses) - cacheElements() {} - initializeComponents() {} - initializeEventHandlers() {} - initializeFromURL() {} + // Hooks (override in subclasses) + cacheElements() {} + initializeComponents() {} + initializeEventHandlers() {} + initializeFromURL() {} - /** - * Cleanup hook (override). Must call super.destroy(). - */ - destroy() { - this.unbindAll(); - } + /** + * Cleanup hook (override). Must call super.destroy(). + */ + destroy() { + this.unbindAll() + } } if (typeof window !== "undefined") { - window.PageController = PageController; + window.PageController = PageController } if (typeof module !== "undefined" && module.exports) { - module.exports = { PageController }; + module.exports = { PageController } } - diff --git a/gateway/sds_gateway/static/js/core/PageGate.js b/gateway/sds_gateway/static/js/core/PageGate.js index 984d34bd7..4a3b9f15e 100644 --- a/gateway/sds_gateway/static/js/core/PageGate.js +++ b/gateway/sds_gateway/static/js/core/PageGate.js @@ -1,71 +1,71 @@ /** * Single entry for environment checks, shared chrome styles, and visualization wiring. */ -(function pageGateModule() { - if (typeof window === "undefined") return; - - function checkRequiredFeatures() { - const requiredFeatures = { - "DOM API": "document" in window && "addEventListener" in document, - "Console API": "console" in window && "log" in console, - Map: "Map" in window, - Set: "Set" in window, - "Template Literals": (() => { - try { - const test = `test${1}`; - return test === "test1"; - } catch { - return false; - } - })(), - }; - - const missingFeatures = Object.entries(requiredFeatures) - .filter(([, supported]) => !supported) - .map(([name]) => name); - - if (missingFeatures.length > 0) { - console.warn("Missing browser features:", missingFeatures); - return false; - } - return true; - } - - function checkBootstrapPresent() { - return ( - "bootstrap" in window || - typeof bootstrap !== "undefined" || - document.querySelector("[data-bs-toggle]") !== null - ); - } - - function checkUploadFeatures() { - const requiredFeatures = { - "File API": "File" in window, - FileReader: "FileReader" in window, - FormData: "FormData" in window, - "Fetch API": "fetch" in window, - Promise: "Promise" in window, - Map: "Map" in window, - Set: "Set" in window, - }; - - const missingFeatures = Object.entries(requiredFeatures) - .filter(([, supported]) => !supported) - .map(([name]) => name); - - if (missingFeatures.length > 0) { - console.warn("Missing browser features:", missingFeatures); - return false; - } - return true; - } - - function injectGatewayChromeStyles() { - if (document.getElementById("gateway-chrome-styles")) return; - const style = document.createElement("style"); - style.id = "gateway-chrome-styles"; - style.textContent = ` +;(function pageGateModule() { + if (typeof window === "undefined") return + + function checkRequiredFeatures() { + const requiredFeatures = { + "DOM API": "document" in window && "addEventListener" in document, + "Console API": "console" in window && "log" in console, + Map: "Map" in window, + Set: "Set" in window, + "Template Literals": (() => { + try { + const test = `test${1}` + return test === "test1" + } catch { + return false + } + })(), + } + + const missingFeatures = Object.entries(requiredFeatures) + .filter(([, supported]) => !supported) + .map(([name]) => name) + + if (missingFeatures.length > 0) { + console.warn("Missing browser features:", missingFeatures) + return false + } + return true + } + + function checkBootstrapPresent() { + return ( + "bootstrap" in window || + typeof bootstrap !== "undefined" || + document.querySelector("[data-bs-toggle]") !== null + ) + } + + function checkUploadFeatures() { + const requiredFeatures = { + "File API": "File" in window, + FileReader: "FileReader" in window, + FormData: "FormData" in window, + "Fetch API": "fetch" in window, + Promise: "Promise" in window, + Map: "Map" in window, + Set: "Set" in window, + } + + const missingFeatures = Object.entries(requiredFeatures) + .filter(([, supported]) => !supported) + .map(([name]) => name) + + if (missingFeatures.length > 0) { + console.warn("Missing browser features:", missingFeatures) + return false + } + return true + } + + function injectGatewayChromeStyles() { + if (document.getElementById("gateway-chrome-styles")) return + const style = document.createElement("style") + style.id = "gateway-chrome-styles" + style.textContent = ` .edit-name-btn:hover i, .save-name-btn:hover i { color: white !important; @@ -75,83 +75,83 @@ -webkit-appearance: none; display: none; } -`; - document.head.appendChild(style); - } - - function wireVisualizationTriggers() { - if (window.VisualizationModal && !window.visualizationModalInstance) { - window.visualizationModalInstance = new window.VisualizationModal(); - } - - if (window.__visualizationTriggerBound) return; - window.__visualizationTriggerBound = true; - - document.addEventListener("click", (e) => { - const button = e.target.closest?.(".visualization-trigger-btn"); - if (!button) return; - const captureUuid = button.getAttribute("data-capture-uuid"); - const captureType = button.getAttribute("data-capture-type"); - - if ( - captureUuid && - captureType && - window.visualizationModalInstance && - typeof window.visualizationModalInstance.openWithCaptureData === - "function" - ) { - window.visualizationModalInstance.openWithCaptureData( - captureUuid, - captureType, - ); - } - }); - } - - function onReady(fn) { - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", fn, { once: true }); - } else { - fn(); - } - } - - function runOnce() { - if (window.__pageGateRan) return; - window.__pageGateRan = true; - - if (!checkRequiredFeatures()) { - void window.DOMUtils?.showError?.( - "Your browser doesn't support required features. Please use a modern browser.", - "browser-compatibility", - ); - return; - } - - injectGatewayChromeStyles(); - - onReady(() => { - if (!checkBootstrapPresent()) { - console.warn( - "Bootstrap not detected. Some UI features may not work properly.", - ); - } - wireVisualizationTriggers(); - }); - } - - window.PageGate = { - runOnce, - checkRequiredFeatures, - checkBootstrapPresent, - checkUploadFeatures, - injectGatewayChromeStyles, - wireVisualizationTriggers, - }; - - runOnce(); -})(); +` + document.head.appendChild(style) + } + + function wireVisualizationTriggers() { + if (window.VisualizationModal && !window.visualizationModalInstance) { + window.visualizationModalInstance = new window.VisualizationModal() + } + + if (window.__visualizationTriggerBound) return + window.__visualizationTriggerBound = true + + document.addEventListener("click", (e) => { + const button = e.target.closest?.(".visualization-trigger-btn") + if (!button) return + const captureUuid = button.getAttribute("data-capture-uuid") + const captureType = button.getAttribute("data-capture-type") + + if ( + captureUuid && + captureType && + window.visualizationModalInstance && + typeof window.visualizationModalInstance.openWithCaptureData === + "function" + ) { + window.visualizationModalInstance.openWithCaptureData( + captureUuid, + captureType, + ) + } + }) + } + + function onReady(fn) { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", fn, { once: true }) + } else { + fn() + } + } + + function runOnce() { + if (window.__pageGateRan) return + window.__pageGateRan = true + + if (!checkRequiredFeatures()) { + void window.DOMUtils?.showError?.( + "Your browser doesn't support required features. Please use a modern browser.", + "browser-compatibility", + ) + return + } + + injectGatewayChromeStyles() + + onReady(() => { + if (!checkBootstrapPresent()) { + console.warn( + "Bootstrap not detected. Some UI features may not work properly.", + ) + } + wireVisualizationTriggers() + }) + } + + window.PageGate = { + runOnce, + checkRequiredFeatures, + checkBootstrapPresent, + checkUploadFeatures, + injectGatewayChromeStyles, + wireVisualizationTriggers, + } + + runOnce() +})() if (typeof module !== "undefined" && module.exports) { - module.exports = {}; + module.exports = {} } diff --git a/gateway/sds_gateway/static/js/core/PageLifecycleManager.js b/gateway/sds_gateway/static/js/core/PageLifecycleManager.js index 1b09a33a7..eed28698c 100644 --- a/gateway/sds_gateway/static/js/core/PageLifecycleManager.js +++ b/gateway/sds_gateway/static/js/core/PageLifecycleManager.js @@ -3,567 +3,580 @@ * Manages initialization, cleanup, and lifecycle of page components */ class PageLifecycleManager { - /** - * Initialize page lifecycle manager - * @param {Object} config - Configuration object - */ - constructor(config) { - this.pageType = config.pageType; // 'dataset-create', 'dataset-edit', 'dataset-list', etc. - this.managers = []; - this.initialized = false; - this.config = config; - this._captureDetailsModalCleanup = null; - - // Core managers - this.permissions = null; - this.datasetModeManager = null; - this.shareActionManager = null; - this.downloadActionManager = null; - this.detailsActionManager = null; - - // Initialize when DOM is ready - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", () => this.initialize()); - } else { - this.initialize(); - } - } - - /** - * Initialize all managers and components - */ - initialize() { - if (this.initialized) { - console.warn("PageLifecycleManager already initialized"); - return; - } - - try { - // Initialize core managers first - this.initializeCoreManagers(); - - // Initialize page-specific managers - this.initializePageSpecificManagers(); - - // Initialize global event listeners - this.initializeGlobalEventListeners(); - - // Mark as initialized - this.initialized = true; - } catch (error) { - console.error("Error initializing PageLifecycleManager:", error); - } - } - - /** - * Initialize core managers - */ - initializeCoreManagers() { - // Initialize permissions manager - if (this.config.permissions && window.PermissionsManager) { - this.permissions = new window.PermissionsManager(this.config.permissions); - this.managers.push(this.permissions); - } - } - - /** - * Initialize page-specific managers - */ - initializePageSpecificManagers() { - switch (this.pageType) { - case "dataset-create": - this.initializeDatasetCreatePage(); - break; - case "dataset-edit": - this.initializeDatasetEditPage(); - break; - case "dataset-list": - this.initializeDatasetListPage(); - break; - case "capture-list": - this.initializeCaptureListPage(); - break; - case "published-datasets-list": - this.initializePublishedDatasetsListPage(); - break; - default: - console.warn(`Unknown page type: ${this.pageType}`); - } - } - - /** - * Initialize dataset create page - */ - _initDatasetModeAndSearch(afterInit) { - if (window.DatasetModeManager) { - this.datasetModeManager = new window.DatasetModeManager({ - ...this.config.dataset, - userPermissionLevel: this.config.permissions?.userPermissionLevel, - currentUserId: this.config.permissions?.currentUserId, - isOwner: this.config.permissions?.isOwner, - datasetPermissions: this.config.permissions?.datasetPermissions, - }); - } - this.managers.push(this.datasetModeManager); - this.initializeSearchHandlers(); - afterInit?.(); - } - - initializeDatasetCreatePage() { - this._initDatasetModeAndSearch(); - } - - /** - * Initialize dataset edit page - */ - initializeDatasetEditPage() { - this._initDatasetModeAndSearch(() => { - if (this.config.dataset?.datasetUuid && window.ShareActionManager) { - this.shareActionManager = new window.ShareActionManager({ - itemUuid: this.config.dataset.datasetUuid, - itemType: "dataset", - permissions: this.permissions, - }); - this.managers.push(this.shareActionManager); - } - }); - } - - /** - * Initialize dataset list page - */ - initializeDatasetListPage() { - // Initialize sort functionality - this.initializeSortFunctionality(); - - // Initialize pagination - this.initializePagination(); - - // Initialize modals for each dataset - this.initializeDatasetModals(); - } - - /** - * Initialize capture list page - */ - initializeCaptureListPage() { - // Initialize sort functionality - this.initializeSortFunctionality(); - - // Initialize pagination - this.initializePagination(); - - // Initialize modals for each capture - this.initializeCaptureModals(); - - this.ensureCaptureDetailsModal(); - } - - /** - * Global asset details modal (#asset-details-modal) + delegated clicks. - */ - ensureCaptureDetailsModal() { - if (this._captureDetailsModalCleanup || !window.ModalManager?.initializeModal) { - return; - } - if (!this.config.permissions) { - return; - } - this._captureDetailsModalCleanup = window.ModalManager.initializeModal({ - registerFilesCaptureCoordinator: true, - detailsClickDelegation: true, - wireAllDataItemModalsShare: true, - permissions: this.config.permissions, - }); - this.managers.push({ - cleanup: () => { - this._captureDetailsModalCleanup?.(); - this._captureDetailsModalCleanup = null; - }, - }); - } - - /** - * Published datasets search page: pagination + dataset modals (same modal wiring as dataset list, no sort UI). - */ - initializePublishedDatasetsListPage() { - this.initializePagination(); - this.initializeDatasetModals(); - } - - /** - * Single DownloadActionManager for document-wide .web-download-btn / SDK buttons (not per modal). - */ - ensureDownloadActionManager() { - if ( - this.downloadActionManager || - !this.permissions || - !window.DownloadActionManager - ) { - return; - } - this.downloadActionManager = new window.DownloadActionManager({ - permissions: this.permissions, - }); - this.managers.push(this.downloadActionManager); - } - - /** - * Initialize search handlers - */ - initializeSearchHandlers() { - // Initialize captures search handler - if (window.SearchHandler) { - const capturesSearchHandler = new window.SearchHandler({ - searchFormId: "captures-search-form", - searchButtonId: "search-captures", - clearButtonId: "clear-captures-search", - tableBodyId: "captures-table-body", - paginationContainerId: "captures-pagination", - type: "captures", - formHandler: this.datasetModeManager?.getHandler(), - isEditMode: this.datasetModeManager?.isInEditMode() || false, - }); - this.managers.push(capturesSearchHandler); - } - - // Initialize files search handler - if (window.SearchHandler) { - const filesSearchHandler = new window.SearchHandler({ - searchFormId: "files-search-form", - searchButtonId: "search-files", - clearButtonId: "clear-files-search", - tableBodyId: "file-tree-table", - paginationContainerId: "files-pagination", - type: "files", - formHandler: this.datasetModeManager?.getHandler(), - isEditMode: this.datasetModeManager?.isInEditMode() || false, - }); - this.managers.push(filesSearchHandler); - } - } - - /** - * Initialize sort functionality - */ - initializeSortFunctionality() { - const sortableHeaders = document.querySelectorAll("th.sortable"); - - for (const header of sortableHeaders) { - // Prevent duplicate event listener attachment - if (header.dataset.sortSetup === "true") { - continue; - } - header.dataset.sortSetup = "true"; - - header.style.cursor = "pointer"; - header.addEventListener("click", () => { - const sortField = header.getAttribute("data-sort"); - const urlParams = new URLSearchParams(window.location.search); - let newOrder = "desc"; - - // If already sorting by this field, toggle order - if ( - urlParams.get("sort_by") === sortField && - urlParams.get("sort_order") === "asc" - ) { - newOrder = "desc"; - } - - // Update URL with new sort parameters - urlParams.set("sort_by", sortField); - urlParams.set("sort_order", newOrder); - urlParams.set("page", "1"); // Reset to first page when sorting - - // Navigate to sorted results - window.location.search = urlParams.toString(); - }); - } - } - - /** - * Initialize pagination - */ - initializePagination() { - const containerIds = [ - "captures-pagination", - "datasets-pagination", - "files-pagination", - ]; - const onPageChange = (page) => { - const urlParams = new URLSearchParams(window.location.search); - urlParams.set("page", String(page)); - window.location.search = urlParams.toString(); - }; - - for (const containerId of containerIds) { - PageLifecycleManager.wireServerRenderedPagination( - containerId, - onPageChange, - ); - } - } - - /** - * Initialize dataset modals - */ - initializeDatasetModals() { - this.ensureDownloadActionManager(); - const detach = window.ModalManager?.initializeModal?.({ - bootstrap: true, - wireListModals: "dataset", - permissions: this.permissions ?? this.config?.permissions, - managersOut: this.managers, - detailsClickDelegation: true, - downloadActionManager: this.downloadActionManager, - }); - if (detach) { - this.managers.push({ cleanup: detach }); - } - } - - /** - * Initialize capture modals - */ - initializeCaptureModals() { - this.ensureDownloadActionManager(); - window.ModalManager?.initializeModal?.({ - bootstrap: true, - wireListModals: "capture", - permissions: this.permissions ?? this.config?.permissions, - managersOut: this.managers, - downloadActionManager: this.downloadActionManager, - }); - } - - /** - * Initialize global event listeners - */ - initializeGlobalEventListeners() { - // Handle window beforeunload for cleanup - window.addEventListener("beforeunload", () => { - this.cleanup(); - }); - } - - /** - * Get manager by type - * @param {string} type - Manager type - * @returns {Object|null} Manager instance - */ - getManager(type) { - switch (type) { - case "permissions": - return this.permissions; - case "datasetMode": - return this.datasetModeManager; - case "shareAction": - return this.shareActionManager; - case "downloadAction": - return this.downloadActionManager; - case "detailsAction": - return this.detailsActionManager; - default: - return this.managers.find( - (manager) => manager.constructor.name === type, - ); - } - } - - /** - * Add manager - * @param {Object} manager - Manager instance - */ - addManager(manager) { - this.managers.push(manager); - } - - /** - * Remove manager - * @param {Object} manager - Manager instance to remove - */ - removeManager(manager) { - const index = this.managers.indexOf(manager); - if (index > -1) { - this.managers.splice(index, 1); - } - } - - /** - * Update configuration - * @param {Object} newConfig - New configuration - */ - updateConfig(newConfig) { - this.config = { ...this.config, ...newConfig }; - - // Update permissions if provided - if (newConfig.permissions && this.permissions) { - this.permissions.updateDatasetPermissions(newConfig.permissions); - } - } - - /** - * Refresh page components - */ - async refresh() { - try { - // Clean up existing managers - this.cleanup(); - - // Reinitialize - this.initialized = false; - this.managers = []; - await this.initialize(); - } catch (error) { - console.error("Error refreshing page components:", error); - } - } - - /** - * Cleanup all managers and resources - */ - cleanup() { - // Cleanup all managers - for (const manager of this.managers) { - if (manager.cleanup && typeof manager.cleanup === "function") { - try { - manager.cleanup(); - } catch (error) { - console.error("Error cleaning up manager:", error); - } - } - } - - // Do not dispose Bootstrap modals here. Disposing all modals before initialize() - // runs creates a gap where getOrCreateInstance can return a disposed instance - // (_config undefined). Modals are disposed and re-created per-element in - // initializeDatasetModals() so there is no gap. - - // Clear manager references from modal elements so re-init creates fresh managers - const datasetModals = document.querySelectorAll( - ".modal[data-item-type='dataset']", - ); - for (const modal of datasetModals) { - modal.shareActionManager = undefined; - modal.versioningActionManager = undefined; - modal.downloadActionManager = undefined; - modal.detailsActionManager = undefined; - } - const captureModals = document.querySelectorAll( - ".modal[data-item-type='capture']", - ); - for (const modal of captureModals) { - modal.shareActionManager = undefined; - modal.downloadActionManager = undefined; - } - - // Clear manager references - this.managers = []; - this.permissions = null; - this.datasetModeManager = null; - this.shareActionManager = null; - this.downloadActionManager = null; - this.detailsActionManager = null; - - // Remove global event listeners - window.removeEventListener("beforeunload", this.cleanup); - - this.initialized = false; - } - - /** - * Debounce function - * @param {Function} func - Function to debounce - * @param {number} wait - Wait time in milliseconds - * @returns {Function} Debounced function - */ - debounce(func, wait) { - let timeout; - return function executedFunction(...args) { - const later = () => { - clearTimeout(timeout); - func(...args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; - } - - /** - * Get page status - * @returns {Object} Page status information - */ - getStatus() { - return { - initialized: this.initialized, - pageType: this.pageType, - managersCount: this.managers.length, - managers: this.managers.map((manager) => manager.constructor.name), - }; - } - - /** - * Check if manager is initialized - * @param {string} type - Manager type - * @returns {boolean} Whether manager is initialized - */ - isManagerInitialized(type) { - return this.getManager(type) !== null; - } - - /** - * Wait for manager to be initialized - * @param {string} type - Manager type - * @param {number} timeout - Timeout in milliseconds - * @returns {Promise} Manager instance - */ - async waitForManager(type, timeout = 5000) { - return new Promise((resolve, reject) => { - const startTime = Date.now(); - - const checkManager = () => { - const manager = this.getManager(type); - if (manager) { - resolve(manager); - } else if (Date.now() - startTime > timeout) { - reject( - new Error(`Manager ${type} not initialized within ${timeout}ms`), - ); - } else { - setTimeout(checkManager, 100); - } - }; - - checkManager(); - }); - } - - /** - * Wire server-rendered Bootstrap pagination links (data-page + .page-link). - * @param {string} containerId - * @param {(page: number) => void} onPageChange - */ - static wireServerRenderedPagination(containerId, onPageChange) { - const el = document.getElementById(containerId); - if (!el || typeof onPageChange !== "function") return; - - const links = el.querySelectorAll(".pagination a.page-link"); - for (const link of links) { - if (link.dataset.paginationSetup === "true") continue; - link.dataset.paginationSetup = "true"; - link.addEventListener("click", (e) => { - e.preventDefault(); - const p = Number.parseInt(link.getAttribute("data-page") || "", 10); - if (p) onPageChange(p); - }); - } - } + /** + * Initialize page lifecycle manager + * @param {Object} config - Configuration object + */ + constructor(config) { + this.pageType = config.pageType // 'dataset-create', 'dataset-edit', 'dataset-list', etc. + this.managers = [] + this.initialized = false + this.config = config + this._captureDetailsModalCleanup = null + + // Core managers + this.permissions = null + this.datasetModeManager = null + this.shareActionManager = null + this.downloadActionManager = null + this.detailsActionManager = null + + // Initialize when DOM is ready + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => + this.initialize(), + ) + } else { + this.initialize() + } + } + + /** + * Initialize all managers and components + */ + initialize() { + if (this.initialized) { + console.warn("PageLifecycleManager already initialized") + return + } + + try { + // Initialize core managers first + this.initializeCoreManagers() + + // Initialize page-specific managers + this.initializePageSpecificManagers() + + // Initialize global event listeners + this.initializeGlobalEventListeners() + + // Mark as initialized + this.initialized = true + } catch (error) { + console.error("Error initializing PageLifecycleManager:", error) + } + } + + /** + * Initialize core managers + */ + initializeCoreManagers() { + // Initialize permissions manager + if (this.config.permissions && window.PermissionsManager) { + this.permissions = new window.PermissionsManager( + this.config.permissions, + ) + this.managers.push(this.permissions) + } + } + + /** + * Initialize page-specific managers + */ + initializePageSpecificManagers() { + switch (this.pageType) { + case "dataset-create": + this.initializeDatasetCreatePage() + break + case "dataset-edit": + this.initializeDatasetEditPage() + break + case "dataset-list": + this.initializeDatasetListPage() + break + case "capture-list": + this.initializeCaptureListPage() + break + case "published-datasets-list": + this.initializePublishedDatasetsListPage() + break + default: + console.warn(`Unknown page type: ${this.pageType}`) + } + } + + /** + * Initialize dataset create page + */ + _initDatasetModeAndSearch(afterInit) { + if (window.DatasetModeManager) { + this.datasetModeManager = new window.DatasetModeManager({ + ...this.config.dataset, + userPermissionLevel: + this.config.permissions?.userPermissionLevel, + currentUserId: this.config.permissions?.currentUserId, + isOwner: this.config.permissions?.isOwner, + datasetPermissions: this.config.permissions?.datasetPermissions, + }) + } + this.managers.push(this.datasetModeManager) + this.initializeSearchHandlers() + afterInit?.() + } + + initializeDatasetCreatePage() { + this._initDatasetModeAndSearch() + } + + /** + * Initialize dataset edit page + */ + initializeDatasetEditPage() { + this._initDatasetModeAndSearch(() => { + if (this.config.dataset?.datasetUuid && window.ShareActionManager) { + this.shareActionManager = new window.ShareActionManager({ + itemUuid: this.config.dataset.datasetUuid, + itemType: "dataset", + permissions: this.permissions, + }) + this.managers.push(this.shareActionManager) + } + }) + } + + /** + * Initialize dataset list page + */ + initializeDatasetListPage() { + // Initialize sort functionality + this.initializeSortFunctionality() + + // Initialize pagination + this.initializePagination() + + // Initialize modals for each dataset + this.initializeDatasetModals() + } + + /** + * Initialize capture list page + */ + initializeCaptureListPage() { + // Initialize sort functionality + this.initializeSortFunctionality() + + // Initialize pagination + this.initializePagination() + + // Initialize modals for each capture + this.initializeCaptureModals() + + this.ensureCaptureDetailsModal() + } + + /** + * Global asset details modal (#asset-details-modal) + delegated clicks. + */ + ensureCaptureDetailsModal() { + if ( + this._captureDetailsModalCleanup || + !window.ModalManager?.initializeModal + ) { + return + } + if (!this.config.permissions) { + return + } + this._captureDetailsModalCleanup = window.ModalManager.initializeModal({ + registerFilesCaptureCoordinator: true, + detailsClickDelegation: true, + wireAllDataItemModalsShare: true, + permissions: this.config.permissions, + }) + this.managers.push({ + cleanup: () => { + this._captureDetailsModalCleanup?.() + this._captureDetailsModalCleanup = null + }, + }) + } + + /** + * Published datasets search page: pagination + dataset modals (same modal wiring as dataset list, no sort UI). + */ + initializePublishedDatasetsListPage() { + this.initializePagination() + this.initializeDatasetModals() + } + + /** + * Single DownloadActionManager for document-wide .web-download-btn / SDK buttons (not per modal). + */ + ensureDownloadActionManager() { + if ( + this.downloadActionManager || + !this.permissions || + !window.DownloadActionManager + ) { + return + } + this.downloadActionManager = new window.DownloadActionManager({ + permissions: this.permissions, + }) + this.managers.push(this.downloadActionManager) + } + + /** + * Initialize search handlers + */ + initializeSearchHandlers() { + // Initialize captures search handler + if (window.SearchHandler) { + const capturesSearchHandler = new window.SearchHandler({ + searchFormId: "captures-search-form", + searchButtonId: "search-captures", + clearButtonId: "clear-captures-search", + tableBodyId: "captures-table-body", + paginationContainerId: "captures-pagination", + type: "captures", + formHandler: this.datasetModeManager?.getHandler(), + isEditMode: this.datasetModeManager?.isInEditMode() || false, + }) + this.managers.push(capturesSearchHandler) + } + + // Initialize files search handler + if (window.SearchHandler) { + const filesSearchHandler = new window.SearchHandler({ + searchFormId: "files-search-form", + searchButtonId: "search-files", + clearButtonId: "clear-files-search", + tableBodyId: "file-tree-table", + paginationContainerId: "files-pagination", + type: "files", + formHandler: this.datasetModeManager?.getHandler(), + isEditMode: this.datasetModeManager?.isInEditMode() || false, + }) + this.managers.push(filesSearchHandler) + } + } + + /** + * Initialize sort functionality + */ + initializeSortFunctionality() { + const sortableHeaders = document.querySelectorAll("th.sortable") + + for (const header of sortableHeaders) { + // Prevent duplicate event listener attachment + if (header.dataset.sortSetup === "true") { + continue + } + header.dataset.sortSetup = "true" + + header.style.cursor = "pointer" + header.addEventListener("click", () => { + const sortField = header.getAttribute("data-sort") + const urlParams = new URLSearchParams(window.location.search) + let newOrder = "desc" + + // If already sorting by this field, toggle order + if ( + urlParams.get("sort_by") === sortField && + urlParams.get("sort_order") === "asc" + ) { + newOrder = "desc" + } + + // Update URL with new sort parameters + urlParams.set("sort_by", sortField) + urlParams.set("sort_order", newOrder) + urlParams.set("page", "1") // Reset to first page when sorting + + // Navigate to sorted results + window.location.search = urlParams.toString() + }) + } + } + + /** + * Initialize pagination + */ + initializePagination() { + const containerIds = [ + "captures-pagination", + "datasets-pagination", + "files-pagination", + ] + const onPageChange = (page) => { + const urlParams = new URLSearchParams(window.location.search) + urlParams.set("page", String(page)) + window.location.search = urlParams.toString() + } + + for (const containerId of containerIds) { + PageLifecycleManager.wireServerRenderedPagination( + containerId, + onPageChange, + ) + } + } + + /** + * Initialize dataset modals + */ + initializeDatasetModals() { + this.ensureDownloadActionManager() + const detach = window.ModalManager?.initializeModal?.({ + bootstrap: true, + wireListModals: "dataset", + permissions: this.permissions ?? this.config?.permissions, + managersOut: this.managers, + detailsClickDelegation: true, + downloadActionManager: this.downloadActionManager, + }) + if (detach) { + this.managers.push({ cleanup: detach }) + } + } + + /** + * Initialize capture modals + */ + initializeCaptureModals() { + this.ensureDownloadActionManager() + window.ModalManager?.initializeModal?.({ + bootstrap: true, + wireListModals: "capture", + permissions: this.permissions ?? this.config?.permissions, + managersOut: this.managers, + downloadActionManager: this.downloadActionManager, + }) + } + + /** + * Initialize global event listeners + */ + initializeGlobalEventListeners() { + // Handle window beforeunload for cleanup + window.addEventListener("beforeunload", () => { + this.cleanup() + }) + } + + /** + * Get manager by type + * @param {string} type - Manager type + * @returns {Object|null} Manager instance + */ + getManager(type) { + switch (type) { + case "permissions": + return this.permissions + case "datasetMode": + return this.datasetModeManager + case "shareAction": + return this.shareActionManager + case "downloadAction": + return this.downloadActionManager + case "detailsAction": + return this.detailsActionManager + default: + return this.managers.find( + (manager) => manager.constructor.name === type, + ) + } + } + + /** + * Add manager + * @param {Object} manager - Manager instance + */ + addManager(manager) { + this.managers.push(manager) + } + + /** + * Remove manager + * @param {Object} manager - Manager instance to remove + */ + removeManager(manager) { + const index = this.managers.indexOf(manager) + if (index > -1) { + this.managers.splice(index, 1) + } + } + + /** + * Update configuration + * @param {Object} newConfig - New configuration + */ + updateConfig(newConfig) { + this.config = { ...this.config, ...newConfig } + + // Update permissions if provided + if (newConfig.permissions && this.permissions) { + this.permissions.updateDatasetPermissions(newConfig.permissions) + } + } + + /** + * Refresh page components + */ + async refresh() { + try { + // Clean up existing managers + this.cleanup() + + // Reinitialize + this.initialized = false + this.managers = [] + await this.initialize() + } catch (error) { + console.error("Error refreshing page components:", error) + } + } + + /** + * Cleanup all managers and resources + */ + cleanup() { + // Cleanup all managers + for (const manager of this.managers) { + if (manager.cleanup && typeof manager.cleanup === "function") { + try { + manager.cleanup() + } catch (error) { + console.error("Error cleaning up manager:", error) + } + } + } + + // Do not dispose Bootstrap modals here. Disposing all modals before initialize() + // runs creates a gap where getOrCreateInstance can return a disposed instance + // (_config undefined). Modals are disposed and re-created per-element in + // initializeDatasetModals() so there is no gap. + + // Clear manager references from modal elements so re-init creates fresh managers + const datasetModals = document.querySelectorAll( + ".modal[data-item-type='dataset']", + ) + for (const modal of datasetModals) { + modal.shareActionManager = undefined + modal.versioningActionManager = undefined + modal.downloadActionManager = undefined + modal.detailsActionManager = undefined + } + const captureModals = document.querySelectorAll( + ".modal[data-item-type='capture']", + ) + for (const modal of captureModals) { + modal.shareActionManager = undefined + modal.downloadActionManager = undefined + } + + // Clear manager references + this.managers = [] + this.permissions = null + this.datasetModeManager = null + this.shareActionManager = null + this.downloadActionManager = null + this.detailsActionManager = null + + // Remove global event listeners + window.removeEventListener("beforeunload", this.cleanup) + + this.initialized = false + } + + /** + * Debounce function + * @param {Function} func - Function to debounce + * @param {number} wait - Wait time in milliseconds + * @returns {Function} Debounced function + */ + debounce(func, wait) { + let timeout + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout) + func(...args) + } + clearTimeout(timeout) + timeout = setTimeout(later, wait) + } + } + + /** + * Get page status + * @returns {Object} Page status information + */ + getStatus() { + return { + initialized: this.initialized, + pageType: this.pageType, + managersCount: this.managers.length, + managers: this.managers.map((manager) => manager.constructor.name), + } + } + + /** + * Check if manager is initialized + * @param {string} type - Manager type + * @returns {boolean} Whether manager is initialized + */ + isManagerInitialized(type) { + return this.getManager(type) !== null + } + + /** + * Wait for manager to be initialized + * @param {string} type - Manager type + * @param {number} timeout - Timeout in milliseconds + * @returns {Promise} Manager instance + */ + async waitForManager(type, timeout = 5000) { + return new Promise((resolve, reject) => { + const startTime = Date.now() + + const checkManager = () => { + const manager = this.getManager(type) + if (manager) { + resolve(manager) + } else if (Date.now() - startTime > timeout) { + reject( + new Error( + `Manager ${type} not initialized within ${timeout}ms`, + ), + ) + } else { + setTimeout(checkManager, 100) + } + } + + checkManager() + }) + } + + /** + * Wire server-rendered Bootstrap pagination links (data-page + .page-link). + * @param {string} containerId + * @param {(page: number) => void} onPageChange + */ + static wireServerRenderedPagination(containerId, onPageChange) { + const el = document.getElementById(containerId) + if (!el || typeof onPageChange !== "function") return + + const links = el.querySelectorAll(".pagination a.page-link") + for (const link of links) { + if (link.dataset.paginationSetup === "true") continue + link.dataset.paginationSetup = "true" + link.addEventListener("click", (e) => { + e.preventDefault() + const p = Number.parseInt( + link.getAttribute("data-page") || "", + 10, + ) + if (p) onPageChange(p) + }) + } + } } // Make class available globally -window.PageLifecycleManager = PageLifecycleManager; +window.PageLifecycleManager = PageLifecycleManager // Export for ES6 modules (Jest testing) - only if in module context if (typeof module !== "undefined" && module.exports) { - module.exports = { PageLifecycleManager }; + module.exports = { PageLifecycleManager } } diff --git a/gateway/sds_gateway/static/js/core/PermissionsManager.js b/gateway/sds_gateway/static/js/core/PermissionsManager.js index add803651..e69f8e746 100644 --- a/gateway/sds_gateway/static/js/core/PermissionsManager.js +++ b/gateway/sds_gateway/static/js/core/PermissionsManager.js @@ -3,338 +3,343 @@ * Handles all permission checking and user access control */ class PermissionsManager { - /** - * Initialize permissions manager - * @param {Object} config - Configuration object - * @param {string} config.userPermissionLevel - User's permission level (viewer, contributor, co-owner, owner) - * @param {string|null} config.datasetUuid - Dataset UUID (null for create mode) - * @param {number} config.currentUserId - Current user ID - * @param {boolean} config.isOwner - Whether user is the owner - * @param {Object} config.datasetPermissions - Dataset-specific permissions - */ - constructor(config) { - this.userPermissionLevel = - config.userPermissionLevel || window.PermissionLevels.VIEWER; - this.datasetUuid = config.datasetUuid; - this.currentUserId = config.currentUserId; - this.isOwner = config.isOwner || false; - this.isEditMode = !!this.datasetUuid; + /** + * Initialize permissions manager + * @param {Object} config - Configuration object + * @param {string} config.userPermissionLevel - User's permission level (viewer, contributor, co-owner, owner) + * @param {string|null} config.datasetUuid - Dataset UUID (null for create mode) + * @param {number} config.currentUserId - Current user ID + * @param {boolean} config.isOwner - Whether user is the owner + * @param {Object} config.datasetPermissions - Dataset-specific permissions + */ + constructor(config) { + this.userPermissionLevel = + config.userPermissionLevel || window.PermissionLevels.VIEWER + this.datasetUuid = config.datasetUuid + this.currentUserId = config.currentUserId + this.isOwner = config.isOwner || false + this.isEditMode = !!this.datasetUuid - // Dataset-specific permissions - this.datasetPermissions = { - canEditMetadata: config.datasetPermissions?.canEditMetadata || false, - canAddAssets: config.datasetPermissions?.canAddAssets || false, - canRemoveAnyAssets: - config.datasetPermissions?.canRemoveAnyAssets || false, - canRemoveOwnAssets: - config.datasetPermissions?.canRemoveOwnAssets || false, - canShare: config.datasetPermissions?.canShare ?? false, - canDownload: config.datasetPermissions?.canDownload ?? false, - ...(config.datasetPermissions ?? {}), - }; - } + // Dataset-specific permissions + this.datasetPermissions = { + canEditMetadata: + config.datasetPermissions?.canEditMetadata || false, + canAddAssets: config.datasetPermissions?.canAddAssets || false, + canRemoveAnyAssets: + config.datasetPermissions?.canRemoveAnyAssets || false, + canRemoveOwnAssets: + config.datasetPermissions?.canRemoveOwnAssets || false, + canShare: config.datasetPermissions?.canShare ?? false, + canDownload: config.datasetPermissions?.canDownload ?? false, + ...(config.datasetPermissions ?? {}), + } + } - /** - * Check if user can edit dataset metadata - * @returns {boolean} - */ - canEditMetadata() { - if ( - this.isOwner || - this.userPermissionLevel === window.PermissionLevels.CO_OWNER - ) - return true; - return this.datasetPermissions.canEditMetadata || false; - } + /** + * Check if user can edit dataset metadata + * @returns {boolean} + */ + canEditMetadata() { + if ( + this.isOwner || + this.userPermissionLevel === window.PermissionLevels.CO_OWNER + ) + return true + return this.datasetPermissions.canEditMetadata || false + } - /** - * Check if user can add assets to dataset - * @returns {boolean} - */ - canAddAssets() { - if ( - this.isOwner || - [ - window.PermissionLevels.CO_OWNER, - window.PermissionLevels.CONTRIBUTOR, - ].includes(this.userPermissionLevel) - ) - return true; - return this.datasetPermissions.canAddAssets || false; - } + /** + * Check if user can add assets to dataset + * @returns {boolean} + */ + canAddAssets() { + if ( + this.isOwner || + [ + window.PermissionLevels.CO_OWNER, + window.PermissionLevels.CONTRIBUTOR, + ].includes(this.userPermissionLevel) + ) + return true + return this.datasetPermissions.canAddAssets || false + } - /** - * Check if user can remove any assets from dataset - * @returns {boolean} - */ - canRemoveOwnAssets() { - if ( - this.isOwner || - [ - window.PermissionLevels.CO_OWNER, - window.PermissionLevels.CONTRIBUTOR, - ].includes(this.userPermissionLevel) - ) - return true; - return this.datasetPermissions.canRemoveOwnAssets || false; - } + /** + * Check if user can remove any assets from dataset + * @returns {boolean} + */ + canRemoveOwnAssets() { + if ( + this.isOwner || + [ + window.PermissionLevels.CO_OWNER, + window.PermissionLevels.CONTRIBUTOR, + ].includes(this.userPermissionLevel) + ) + return true + return this.datasetPermissions.canRemoveOwnAssets || false + } - /** - * Check if user can remove assets from dataset - * @returns {boolean} - */ - canRemoveAnyAssets() { - if ( - this.isOwner || - this.userPermissionLevel === window.PermissionLevels.CO_OWNER - ) - return true; - return this.datasetPermissions.canRemoveAnyAssets || false; - } + /** + * Check if user can remove assets from dataset + * @returns {boolean} + */ + canRemoveAnyAssets() { + if ( + this.isOwner || + this.userPermissionLevel === window.PermissionLevels.CO_OWNER + ) + return true + return this.datasetPermissions.canRemoveAnyAssets || false + } - /** - * Check if user can share dataset - * @returns {boolean} - */ - canShare() { - if ( - this.isOwner || - this.userPermissionLevel === window.PermissionLevels.CO_OWNER - ) - return true; - return this.datasetPermissions.canShare; - } + /** + * Check if user can share dataset + * @returns {boolean} + */ + canShare() { + if ( + this.isOwner || + this.userPermissionLevel === window.PermissionLevels.CO_OWNER + ) + return true + return this.datasetPermissions.canShare + } - /** - * Check if user can download dataset - * @returns {boolean} - */ - canDownload() { - if ( - this.isOwner || - [ - window.PermissionLevels.CO_OWNER, - window.PermissionLevels.CONTRIBUTOR, - window.PermissionLevels.VIEWER, - ].includes(this.userPermissionLevel) - ) - return true; - return this.datasetPermissions.canDownload || false; - } + /** + * Check if user can download dataset + * @returns {boolean} + */ + canDownload() { + if ( + this.isOwner || + [ + window.PermissionLevels.CO_OWNER, + window.PermissionLevels.CONTRIBUTOR, + window.PermissionLevels.VIEWER, + ].includes(this.userPermissionLevel) + ) + return true + return this.datasetPermissions.canDownload || false + } - /** - * Check if user can view dataset - * @returns {boolean} - */ - canView() { - return [ - window.PermissionLevels.OWNER, - window.PermissionLevels.CO_OWNER, - window.PermissionLevels.CONTRIBUTOR, - window.PermissionLevels.VIEWER, - ].includes(this.userPermissionLevel); - } + /** + * Check if user can view dataset + * @returns {boolean} + */ + canView() { + return [ + window.PermissionLevels.OWNER, + window.PermissionLevels.CO_OWNER, + window.PermissionLevels.CONTRIBUTOR, + window.PermissionLevels.VIEWER, + ].includes(this.userPermissionLevel) + } - /** - * Check if user can edit specific asset (capture/file) - * @param {Object} asset - Asset object - * @returns {boolean} - */ - canRemoveAsset(asset) { - if ( - this.isOwner || - this.userPermissionLevel === window.PermissionLevels.CO_OWNER - ) - return true; + /** + * Check if user can edit specific asset (capture/file) + * @param {Object} asset - Asset object + * @returns {boolean} + */ + canRemoveAsset(asset) { + if ( + this.isOwner || + this.userPermissionLevel === window.PermissionLevels.CO_OWNER + ) + return true - // Check if asset is owned by current user - const isAssetOwner = asset.owner_id === this.currentUserId; + // Check if asset is owned by current user + const isAssetOwner = asset.owner_id === this.currentUserId - // Contributors can edit their own assets - if ( - this.userPermissionLevel === window.PermissionLevels.CONTRIBUTOR && - isAssetOwner - ) - return true; + // Contributors can edit their own assets + if ( + this.userPermissionLevel === window.PermissionLevels.CONTRIBUTOR && + isAssetOwner + ) + return true - return false; - } + return false + } - /** - * Check if user can add specific asset (capture/file) - * @param {Object} asset - Asset object - * @returns {boolean} - */ - canAddAsset(asset) { - if ( - this.isOwner || - this.userPermissionLevel === window.PermissionLevels.CO_OWNER - ) - return true; + /** + * Check if user can add specific asset (capture/file) + * @param {Object} asset - Asset object + * @returns {boolean} + */ + canAddAsset(asset) { + if ( + this.isOwner || + this.userPermissionLevel === window.PermissionLevels.CO_OWNER + ) + return true - // Check if asset is owned by current user - const isAssetOwner = asset.owner_id === this.currentUserId; + // Check if asset is owned by current user + const isAssetOwner = asset.owner_id === this.currentUserId - // Contributors can add their own assets - if ( - this.userPermissionLevel === window.PermissionLevels.CONTRIBUTOR && - isAssetOwner - ) { - return true; - } + // Contributors can add their own assets + if ( + this.userPermissionLevel === window.PermissionLevels.CONTRIBUTOR && + isAssetOwner + ) { + return true + } - return false; - } + return false + } - /** - * Get the appropriate removal permission level for UI display - * @returns {string} 'any', 'own', or 'none' - */ - getRemovalPermissionLevel() { - if (this.canRemoveAnyAssets()) { - return "any"; - } - if (this.canRemoveOwnAssets()) { - return "own"; - } - return "none"; - } + /** + * Get the appropriate removal permission level for UI display + * @returns {string} 'any', 'own', or 'none' + */ + getRemovalPermissionLevel() { + if (this.canRemoveAnyAssets()) { + return "any" + } + if (this.canRemoveOwnAssets()) { + return "own" + } + return "none" + } - /** - * Get permission level display name - * @param {string} level - Permission level - * @returns {string} Display name - */ - getPermissionDisplayName(level) { - const displayNames = { - [window.PermissionLevels.OWNER]: "Owner", - [window.PermissionLevels.CO_OWNER]: "Co-Owner", - [window.PermissionLevels.CONTRIBUTOR]: "Contributor", - [window.PermissionLevels.VIEWER]: "Viewer", - }; - return displayNames[level] || level; - } + /** + * Get permission level display name + * @param {string} level - Permission level + * @returns {string} Display name + */ + getPermissionDisplayName(level) { + const displayNames = { + [window.PermissionLevels.OWNER]: "Owner", + [window.PermissionLevels.CO_OWNER]: "Co-Owner", + [window.PermissionLevels.CONTRIBUTOR]: "Contributor", + [window.PermissionLevels.VIEWER]: "Viewer", + } + return displayNames[level] || level + } - /** - * Get permission level description - * @param {string} level - Permission level - * @returns {string} Description - */ - getPermissionDescription(level) { - const descriptions = { - [window.PermissionLevels.OWNER]: - "Full control over the dataset including deletion and sharing", - [window.PermissionLevels.CO_OWNER]: - "Can edit metadata, add/remove assets, and share the dataset", - [window.PermissionLevels.CONTRIBUTOR]: - "Can add and remove their own assets and view others' additions", - [window.PermissionLevels.VIEWER]: - "Can only view and download the dataset", - }; - return descriptions[level] || "Unknown permission level"; - } + /** + * Get permission level description + * @param {string} level - Permission level + * @returns {string} Description + */ + getPermissionDescription(level) { + const descriptions = { + [window.PermissionLevels.OWNER]: + "Full control over the dataset including deletion and sharing", + [window.PermissionLevels.CO_OWNER]: + "Can edit metadata, add/remove assets, and share the dataset", + [window.PermissionLevels.CONTRIBUTOR]: + "Can add and remove their own assets and view others' additions", + [window.PermissionLevels.VIEWER]: + "Can only view and download the dataset", + } + return descriptions[level] || "Unknown permission level" + } - /** - * Get permission level icon class - * @param {string} level - Permission level - * @returns {string} Icon class - */ - getPermissionIcon(level) { - const icons = { - [window.PermissionLevels.OWNER]: "bi-person-circle", - [window.PermissionLevels.CO_OWNER]: "bi-gear", - [window.PermissionLevels.CONTRIBUTOR]: "bi-plus-circle", - [window.PermissionLevels.VIEWER]: "bi-eye", - remove: "bi-person-slash", - }; - return icons[level] || "bi-question-circle"; - } + /** + * Get permission level icon class + * @param {string} level - Permission level + * @returns {string} Icon class + */ + getPermissionIcon(level) { + const icons = { + [window.PermissionLevels.OWNER]: "bi-person-circle", + [window.PermissionLevels.CO_OWNER]: "bi-gear", + [window.PermissionLevels.CONTRIBUTOR]: "bi-plus-circle", + [window.PermissionLevels.VIEWER]: "bi-eye", + remove: "bi-person-slash", + } + return icons[level] || "bi-question-circle" + } - /** - * Get permission level badge class - * @param {string} level - Permission level - * @returns {string} Badge class - */ - getPermissionBadgeClass(level) { - const badgeClasses = { - [window.PermissionLevels.OWNER]: "bg-owner", - [window.PermissionLevels.CO_OWNER]: "bg-co-owner", - [window.PermissionLevels.CONTRIBUTOR]: "bg-contributor", - [window.PermissionLevels.VIEWER]: "bg-viewer", - }; - return badgeClasses[level] || "bg-light"; - } + /** + * Get permission level badge class + * @param {string} level - Permission level + * @returns {string} Badge class + */ + getPermissionBadgeClass(level) { + const badgeClasses = { + [window.PermissionLevels.OWNER]: "bg-owner", + [window.PermissionLevels.CO_OWNER]: "bg-co-owner", + [window.PermissionLevels.CONTRIBUTOR]: "bg-contributor", + [window.PermissionLevels.VIEWER]: "bg-viewer", + } + return badgeClasses[level] || "bg-light" + } - /** - * Get permission summary for display - * @returns {Object} Permission summary - */ - getPermissionSummary() { - return { - userPermissionLevel: this.userPermissionLevel, - displayName: this.getPermissionDisplayName(this.userPermissionLevel), - description: this.getPermissionDescription(this.userPermissionLevel), - icon: this.getPermissionIcon(this.userPermissionLevel), - badgeClass: this.getPermissionBadgeClass(this.userPermissionLevel), - isEditMode: this.isEditMode, - isOwner: this.isOwner, - permissions: { - canEditMetadata: this.canEditMetadata(), - canAddAssets: this.canAddAssets(), - canRemoveAnyAssets: this.canRemoveAnyAssets(), - canRemoveOwnAssets: this.canRemoveOwnAssets(), - removalPermissionLevel: this.getRemovalPermissionLevel(), - canShare: this.canShare(), - canDownload: this.canDownload(), - canView: this.canView(), - }, - }; - } + /** + * Get permission summary for display + * @returns {Object} Permission summary + */ + getPermissionSummary() { + return { + userPermissionLevel: this.userPermissionLevel, + displayName: this.getPermissionDisplayName( + this.userPermissionLevel, + ), + description: this.getPermissionDescription( + this.userPermissionLevel, + ), + icon: this.getPermissionIcon(this.userPermissionLevel), + badgeClass: this.getPermissionBadgeClass(this.userPermissionLevel), + isEditMode: this.isEditMode, + isOwner: this.isOwner, + permissions: { + canEditMetadata: this.canEditMetadata(), + canAddAssets: this.canAddAssets(), + canRemoveAnyAssets: this.canRemoveAnyAssets(), + canRemoveOwnAssets: this.canRemoveOwnAssets(), + removalPermissionLevel: this.getRemovalPermissionLevel(), + canShare: this.canShare(), + canDownload: this.canDownload(), + canView: this.canView(), + }, + } + } - /** - * Update dataset permissions - * @param {Object} newPermissions - New permissions object - */ - updateDatasetPermissions(newPermissions) { - this.datasetPermissions = { - ...this.datasetPermissions, - ...newPermissions, - }; - } + /** + * Update dataset permissions + * @param {Object} newPermissions - New permissions object + */ + updateDatasetPermissions(newPermissions) { + this.datasetPermissions = { + ...this.datasetPermissions, + ...newPermissions, + } + } - /** - * Check if user has any of the specified permissions - * @param {Array} permissionNames - Array of permission names to check - * @returns {boolean} True if user has any of the permissions - */ - hasAnyPermission(permissionNames) { - return permissionNames.some((permission) => { - if (typeof this[permission] === "function") { - return this[permission](); - } - return this.datasetPermissions[permission] || false; - }); - } + /** + * Check if user has any of the specified permissions + * @param {Array} permissionNames - Array of permission names to check + * @returns {boolean} True if user has any of the permissions + */ + hasAnyPermission(permissionNames) { + return permissionNames.some((permission) => { + if (typeof this[permission] === "function") { + return this[permission]() + } + return this.datasetPermissions[permission] || false + }) + } - /** - * Check if user has all of the specified permissions - * @param {Array} permissionNames - Array of permission names to check - * @returns {boolean} True if user has all of the permissions - */ - hasAllPermissions(permissionNames) { - return permissionNames.every((permission) => { - if (typeof this[permission] === "function") { - return this[permission](); - } - return this.datasetPermissions[permission] || false; - }); - } + /** + * Check if user has all of the specified permissions + * @param {Array} permissionNames - Array of permission names to check + * @returns {boolean} True if user has all of the permissions + */ + hasAllPermissions(permissionNames) { + return permissionNames.every((permission) => { + if (typeof this[permission] === "function") { + return this[permission]() + } + return this.datasetPermissions[permission] || false + }) + } } // Make class available globally -window.PermissionsManager = PermissionsManager; +window.PermissionsManager = PermissionsManager // Export for ES6 modules (Jest testing) - only if in module context if (typeof module !== "undefined" && module.exports) { - module.exports = { PermissionsManager }; + module.exports = { PermissionsManager } } diff --git a/gateway/sds_gateway/static/js/core/UserInputController.js b/gateway/sds_gateway/static/js/core/UserInputController.js index 11cdf244f..639d252f2 100644 --- a/gateway/sds_gateway/static/js/core/UserInputController.js +++ b/gateway/sds_gateway/static/js/core/UserInputController.js @@ -3,159 +3,161 @@ * DOM rendering stays in DOMUtils; this module handles input/copy patterns. */ class UserInputController { - /** - * Copy text using the hidden-textarea + execCommand fallback (no Clipboard API). - * @param {string} text - */ - static execCommandCopyFallback(text) { - const textArea = document.createElement("textarea"); - textArea.value = text; - textArea.style.position = "fixed"; - textArea.style.left = "-999999px"; - textArea.style.top = "-999999px"; - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - try { - document.execCommand("copy"); - } finally { - document.body.removeChild(textArea); - } - } + /** + * Copy text using the hidden-textarea + execCommand fallback (no Clipboard API). + * @param {string} text + */ + static execCommandCopyFallback(text) { + const textArea = document.createElement("textarea") + textArea.value = text + textArea.style.position = "fixed" + textArea.style.left = "-999999px" + textArea.style.top = "-999999px" + document.body.appendChild(textArea) + textArea.focus() + textArea.select() + try { + document.execCommand("copy") + } finally { + document.body.removeChild(textArea) + } + } - /** - * Try navigator.clipboard, then {@link execCommandCopyFallback}. - * @param {string} text - * @returns {Promise} - */ - static async copyTextToClipboard(text) { - if (navigator.clipboard && window.isSecureContext) { - await navigator.clipboard.writeText(text); - return; - } - UserInputController.execCommandCopyFallback(text); - } + /** + * Try navigator.clipboard, then {@link execCommandCopyFallback}. + * @param {string} text + * @returns {Promise} + */ + static async copyTextToClipboard(text) { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text) + return + } + UserInputController.execCommandCopyFallback(text) + } - /** - * Wire debounced user search, keyboard navigation, and dropdown dismissal. - * @param {HTMLInputElement} input - * @param {{ - * selectedUsersMap: Record>, - * getSearchTimeout: () => number | null | undefined, - * setSearchTimeout: (id: number | null) => void, - * getDropdownForInput: (input: HTMLElement) => HTMLElement | null, - * hideDropdown: (dropdown: HTMLElement) => void, - * navigateDropdown: ( - * items: NodeListOf | NodeList, - * currentIndex: number, - * direction: number, - * ) => void, - * searchUsers: (query: string, dropdown: HTMLElement) => void, - * selectUser: (item: HTMLElement, input: HTMLInputElement) => void, - * }} adapter - */ - static bindUserSearchInput(input, adapter) { - if (input.dataset.searchSetup === "true") { - return; - } - input.dataset.searchSetup = "true"; + /** + * Wire debounced user search, keyboard navigation, and dropdown dismissal. + * @param {HTMLInputElement} input + * @param {{ + * selectedUsersMap: Record>, + * getSearchTimeout: () => number | null | undefined, + * setSearchTimeout: (id: number | null) => void, + * getDropdownForInput: (input: HTMLElement) => HTMLElement | null, + * hideDropdown: (dropdown: HTMLElement) => void, + * navigateDropdown: ( + * items: NodeListOf | NodeList, + * currentIndex: number, + * direction: number, + * ) => void, + * searchUsers: (query: string, dropdown: HTMLElement) => void, + * selectUser: (item: HTMLElement, input: HTMLInputElement) => void, + * }} adapter + */ + static bindUserSearchInput(input, adapter) { + if (input.dataset.searchSetup === "true") { + return + } + input.dataset.searchSetup = "true" - const dropdown = adapter.getDropdownForInput(input); - if (!dropdown) { - return; - } + const dropdown = adapter.getDropdownForInput(input) + if (!dropdown) { + return + } - const form = input.closest("form"); - const inputId = input.id; - if (!adapter.selectedUsersMap[inputId]) { - adapter.selectedUsersMap[inputId] = []; - } + const form = input.closest("form") + const inputId = input.id + if (!adapter.selectedUsersMap[inputId]) { + adapter.selectedUsersMap[inputId] = [] + } - input.addEventListener("input", (e) => { - const prev = adapter.getSearchTimeout(); - if (prev) { - clearTimeout(prev); - } - const query = /** @type {HTMLInputElement} */ (e.target).value.trim(); + input.addEventListener("input", (e) => { + const prev = adapter.getSearchTimeout() + if (prev) { + clearTimeout(prev) + } + const query = /** @type {HTMLInputElement} */ ( + e.target + ).value.trim() - if (query.length < 2) { - adapter.hideDropdown(dropdown); - adapter.setSearchTimeout(null); - return; - } + if (query.length < 2) { + adapter.hideDropdown(dropdown) + adapter.setSearchTimeout(null) + return + } - const id = window.setTimeout(() => { - adapter.searchUsers(query, dropdown); - }, 300); - adapter.setSearchTimeout(id); - }); + const id = window.setTimeout(() => { + adapter.searchUsers(query, dropdown) + }, 300) + adapter.setSearchTimeout(id) + }) - input.addEventListener("keydown", (e) => { - const visibleItems = dropdown.querySelectorAll( - ".list-group-item:not(.no-results)", - ); - const currentIndex = Array.from(visibleItems).findIndex((item) => - item.classList.contains("selected"), - ); + input.addEventListener("keydown", (e) => { + const visibleItems = dropdown.querySelectorAll( + ".list-group-item:not(.no-results)", + ) + const currentIndex = Array.from(visibleItems).findIndex((item) => + item.classList.contains("selected"), + ) - switch (e.key) { - case "ArrowDown": - e.preventDefault(); - adapter.navigateDropdown(visibleItems, currentIndex, 1); - break; - case "ArrowUp": - e.preventDefault(); - adapter.navigateDropdown(visibleItems, currentIndex, -1); - break; - case "Enter": { - e.preventDefault(); - const selectedItem = dropdown.querySelector( - ".list-group-item.selected", - ); - if (selectedItem) { - adapter.selectUser(selectedItem, input); - } - break; - } - case "Escape": - adapter.hideDropdown(dropdown); - input.blur(); - break; - default: - break; - } - }); + switch (e.key) { + case "ArrowDown": + e.preventDefault() + adapter.navigateDropdown(visibleItems, currentIndex, 1) + break + case "ArrowUp": + e.preventDefault() + adapter.navigateDropdown(visibleItems, currentIndex, -1) + break + case "Enter": { + e.preventDefault() + const selectedItem = dropdown.querySelector( + ".list-group-item.selected", + ) + if (selectedItem) { + adapter.selectUser(selectedItem, input) + } + break + } + case "Escape": + adapter.hideDropdown(dropdown) + input.blur() + break + default: + break + } + }) - document.addEventListener("click", (e) => { - const t = /** @type {Node} */ (e.target); - if (!input.contains(t) && !dropdown.contains(t)) { - adapter.hideDropdown(dropdown); - } - }); + document.addEventListener("click", (e) => { + const t = /** @type {Node} */ (e.target) + if (!input.contains(t) && !dropdown.contains(t)) { + adapter.hideDropdown(dropdown) + } + }) - dropdown.addEventListener("click", (e) => { - const item = /** @type {HTMLElement} */ (e.target).closest( - ".list-group-item", - ); - if (item && !item.classList.contains("no-results")) { - e.preventDefault(); - e.stopPropagation(); - adapter.selectUser(item, input); - } - }); + dropdown.addEventListener("click", (e) => { + const item = /** @type {HTMLElement} */ (e.target).closest( + ".list-group-item", + ) + if (item && !item.classList.contains("no-results")) { + e.preventDefault() + e.stopPropagation() + adapter.selectUser(item, input) + } + }) - if (form) { - form.addEventListener("submit", () => { - input.value = adapter.selectedUsersMap[inputId] - .map((u) => /** @type {{ email: string }} */ (u).email) - .join(","); - }); - } - } + if (form) { + form.addEventListener("submit", () => { + input.value = adapter.selectedUsersMap[inputId] + .map((u) => /** @type {{ email: string }} */ (u).email) + .join(",") + }) + } + } } -window.UserInputController = UserInputController; +window.UserInputController = UserInputController if (typeof module !== "undefined" && module.exports) { - module.exports = { UserInputController }; + module.exports = { UserInputController } } diff --git a/gateway/sds_gateway/static/js/core/__tests__/APIClient.test.js b/gateway/sds_gateway/static/js/core/__tests__/APIClient.test.js index c4f1dbb36..7f86c7e4d 100644 --- a/gateway/sds_gateway/static/js/core/__tests__/APIClient.test.js +++ b/gateway/sds_gateway/static/js/core/__tests__/APIClient.test.js @@ -4,399 +4,411 @@ */ // Import the APIClient class -import { APIClient } from "../APIClient.js"; +import { APIClient } from "../APIClient.js" // Mock APIError class class APIError extends Error { - constructor(message, status, data) { - super(message); - this.name = "APIError"; - this.status = status; - this.data = data; - } + constructor(message, status, data) { + super(message) + this.name = "APIError" + this.status = status + this.data = data + } } -global.APIError = APIError; +global.APIError = APIError const { - installMockWindowOrigin, - mockFetchResolved, - installMinimalDocumentForApiClient, - installCsrfMetaToken, - createMockFetchResponse, -} = require("../../tests-config/testHelpers.js"); + installMockWindowOrigin, + mockFetchResolved, + installMinimalDocumentForApiClient, + installCsrfMetaToken, + createMockFetchResponse, +} = require("../../tests-config/testHelpers.js") describe("APIClient", () => { - let apiClient; - let mockFetch; - - beforeEach(() => { - jest.clearAllMocks(); - mockFetch = jest.fn(); - global.fetch = mockFetch; - installMinimalDocumentForApiClient(); - apiClient = new APIClient(); - }); - - describe("CSRF Token Management", () => { - test("should get CSRF token from meta tag", () => { - const mockMetaToken = { - getAttribute: jest.fn(() => "test-csrf-token"), - }; - global.document.querySelector = jest.fn((selector) => { - if (selector === 'meta[name="csrf-token"]') return mockMetaToken; - return null; - }); - - const token = apiClient.getCSRFToken(); - - expect(token).toBe("test-csrf-token"); - expect(mockMetaToken.getAttribute).toHaveBeenCalledWith("content"); - }); - - test("should fallback to input field for CSRF token", () => { - const mockInputToken = { - value: "input-csrf-token", - }; - global.document.querySelector = jest.fn((selector) => { - if (selector === 'meta[name="csrf-token"]') return null; - if (selector === '[name="csrfmiddlewaretoken"]') return mockInputToken; - return null; - }); - - const token = apiClient.getCSRFToken(); - - expect(token).toBe("input-csrf-token"); - }); - - test("should fallback to cookie for CSRF token", () => { - global.document.cookie = "csrftoken=cookie-csrf-token; other=value"; - global.document.querySelector = jest.fn(() => null); - - const token = apiClient.getCSRFToken(); - - expect(token).toBe("cookie-csrf-token"); - }); - - test("should return empty string when no CSRF token found", () => { - // Mock document with no cookie and no DOM elements - const originalCookie = global.document.cookie; - Object.defineProperty(global.document, "cookie", { - writable: true, - value: "", - }); - global.document.querySelector = jest.fn(() => null); - - const token = apiClient.getCSRFToken(); - - expect(token).toBe(""); - - // Restore cookie - Object.defineProperty(global.document, "cookie", { - writable: true, - value: originalCookie, - }); - }); - }); - - describe("Cookie Management", () => { - test.each([ - [ - "test-cookie=test-value; other-cookie=other-value", - "test-cookie", - "test-value", - ], - ["other-cookie=other-value", "test-cookie", null], - ["", "test-cookie", null], - [ - "test-cookie=test%20value%20encoded", - "test-cookie", - "test value encoded", - ], - ])( - "should handle cookie '%s' - get '%s' returns '%s'", - (cookieString, cookieName, expected) => { - Object.defineProperty(global.document, "cookie", { - writable: true, - value: cookieString, - }); - - const value = apiClient.getCookie(cookieName); - - expect(value).toBe(expected); - }, - ); - }); - - describe("API Requests", () => { - test("should make successful GET request", async () => { - mockFetchResolved(mockFetch); - - const result = await apiClient.request("/api/test"); - - // Check that fetch was called with correct headers - expect(mockFetch).toHaveBeenCalled(); - const callArgs = mockFetch.mock.calls[0]; - expect(callArgs[1].headers["X-Requested-With"]).toBe("XMLHttpRequest"); - expect(result).toEqual({ success: true }); - }); - - test("should make POST request with CSRF token", async () => { - mockFetchResolved(mockFetch); - installCsrfMetaToken(); - - await apiClient.request("/api/test", { - method: "POST", - body: JSON.stringify({ test: "data" }), - }); - - expect(mockFetch).toHaveBeenCalledWith("/api/test", { - method: "POST", - body: JSON.stringify({ test: "data" }), - headers: { - "X-Requested-With": "XMLHttpRequest", - "X-CSRFToken": "test-csrf-token", - }, - }); - }); - - test("should handle loading state", async () => { - mockFetchResolved(mockFetch); - - // Create a loading state manager to track loading - const mockLoadingState = { - setLoading: jest.fn(), - }; - - await apiClient.request("/api/test", {}, mockLoadingState); - - // Verify loading state was set to true and then false - expect(mockLoadingState.setLoading).toHaveBeenCalledWith(true); - expect(mockLoadingState.setLoading).toHaveBeenCalledWith(false); - }); - - test("should handle non-JSON response", async () => { - mockFetch.mockResolvedValue( - createMockFetchResponse({ - contentType: "text/plain", - }), - ); - - const result = await apiClient.request("/api/test"); - - expect(result).toBe("plain text"); - }); - - test("should handle HTTP error responses", async () => { - mockFetch.mockResolvedValue( - createMockFetchResponse({ - ok: false, - status: 404, - jsonData: { error: "Resource not found" }, - }), - ); - - await expect(apiClient.request("/api/test")).rejects.toThrow(); - }); - - test("should handle network errors", async () => { - mockFetch.mockRejectedValue(new Error("Network error")); - - await expect(apiClient.request("/api/test")).rejects.toThrow( - "Network error", - ); - }); - - test("should handle JSON parse errors", async () => { - mockFetch.mockResolvedValue( - createMockFetchResponse({ - jsonReject: new Error("Invalid JSON"), - }), - ); - - await expect(apiClient.request("/api/test")).rejects.toThrow( - "Invalid JSON", - ); - }); - }); - - describe("Convenience Methods", () => { - beforeEach(() => { - installMockWindowOrigin(); - }); - - test("should make GET request", async () => { - mockFetchResolved(mockFetch); - - await apiClient.get("/api/test"); - - // Verify fetch was called with correct URL and parameters - expect(mockFetch).toHaveBeenCalledWith( - "http://localhost:8000/api/test", - expect.objectContaining({ - method: "GET", - headers: expect.objectContaining({ - "X-Requested-With": "XMLHttpRequest", - }), - }), - ); - }); - - test("should make GET request with query parameters", async () => { - mockFetchResolved(mockFetch); - - await apiClient.get("/api/test", { param1: "value1", param2: "value2" }); - - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining("/api/test?param1=value1¶m2=value2"), - expect.objectContaining({ - method: "GET", - }), - ); - }); - - test.each([ - ["POST", "post"], - ["PUT", "put"], - ["PATCH", "patch"], - ])( - "should make %s request with FormData and CSRF token", - async (method, methodName) => { - mockFetchResolved(mockFetch); - installCsrfMetaToken(); - - await apiClient[methodName]("/api/test", { data: "test" }); - - // Verify request was made with FormData and CSRF token - expect(mockFetch).toHaveBeenCalled(); - const callArgs = mockFetch.mock.calls[0]; - expect(callArgs[0]).toBe("/api/test"); - expect(callArgs[1].method).toBe(method); - expect(callArgs[1].body).toBeInstanceOf(FormData); - expect(callArgs[1].headers["X-CSRFToken"]).toBe("test-csrf-token"); - }, - ); - }); - - describe("Error Handling", () => { - test("should create APIError with correct properties", async () => { - mockFetch.mockResolvedValue( - createMockFetchResponse({ - ok: false, - status: 404, - jsonData: { error: "Resource not found" }, - }), - ); - - try { - await apiClient.request("/api/test"); - // Should not reach here - expect(true).toBe(false); - } catch (error) { - // Check error properties - expect(error.name).toBe("APIError"); - expect(error.message).toBe("HTTP 404: Not Found"); - expect(error.status).toBe(404); - expect(error.data).toEqual({ error: "Resource not found" }); - } - }); - - test("should handle loading state errors", async () => { - mockFetch.mockRejectedValue(new Error("Network error")); - - const mockLoadingState = { - setLoading: jest.fn(), - }; - - try { - await apiClient.request("/api/test", {}, mockLoadingState); - } catch (error) { - // Verify loading state was set to false even on error - expect(mockLoadingState.setLoading).toHaveBeenCalledWith(true); - expect(mockLoadingState.setLoading).toHaveBeenCalledWith(false); - } - }); - }); - - describe("URL Parameter Handling", () => { - beforeEach(() => { - installMockWindowOrigin(); - }); - - test("should handle empty parameters", async () => { - mockFetchResolved(mockFetch); - - await apiClient.get("/api/test", {}); - - expect(mockFetch).toHaveBeenCalledWith( - "http://localhost:8000/api/test", - expect.any(Object), - ); - }); - - test("should handle multiple parameters", async () => { - mockFetchResolved(mockFetch); - - await apiClient.get("/api/test", { - param1: "value1", - param2: "value2", - param3: "value3", - }); - - const url = mockFetch.mock.calls[0][0]; - expect(url).toContain("param1=value1"); - expect(url).toContain("param2=value2"); - expect(url).toContain("param3=value3"); - }); - - test("should encode special characters in parameters", async () => { - mockFetchResolved(mockFetch); - - await apiClient.get("/api/test", { query: "test value & special=chars" }); - - const url = mockFetch.mock.calls[0][0]; - expect(url).toContain("test%20value"); - expect(url).toContain("%26"); - expect(url).toContain("%3D"); - }); - - test("should filter out null and undefined parameters", async () => { - mockFetchResolved(mockFetch); - - await apiClient.get("/api/test", { - valid: "value", - nullParam: null, - undefinedParam: undefined, - }); - - const url = mockFetch.mock.calls[0][0]; - expect(url).toContain("valid=value"); - expect(url).not.toContain("nullParam"); - expect(url).not.toContain("undefinedParam"); - }); - }); - - describe("Content Type Detection", () => { - test.each([ - [null, "plain text"], - ["invalid-content-type", "plain text"], - ["text/plain", "plain text"], - ["application/json", { success: true }], - ])( - "should handle content-type '%s' correctly", - async (contentType, expectedResult) => { - mockFetch.mockResolvedValue( - createMockFetchResponse({ contentType }), - ); - - const result = await apiClient.request("/api/test"); - - if (contentType?.includes("application/json")) { - expect(result).toEqual(expectedResult); - } else { - expect(result).toBe(expectedResult); - } - }, - ); - }); -}); + let apiClient + let mockFetch + + beforeEach(() => { + jest.clearAllMocks() + mockFetch = jest.fn() + global.fetch = mockFetch + installMinimalDocumentForApiClient() + apiClient = new APIClient() + }) + + describe("CSRF Token Management", () => { + test("should get CSRF token from meta tag", () => { + const mockMetaToken = { + getAttribute: jest.fn(() => "test-csrf-token"), + } + global.document.querySelector = jest.fn((selector) => { + if (selector === 'meta[name="csrf-token"]') return mockMetaToken + return null + }) + + const token = apiClient.getCSRFToken() + + expect(token).toBe("test-csrf-token") + expect(mockMetaToken.getAttribute).toHaveBeenCalledWith("content") + }) + + test("should fallback to input field for CSRF token", () => { + const mockInputToken = { + value: "input-csrf-token", + } + global.document.querySelector = jest.fn((selector) => { + if (selector === 'meta[name="csrf-token"]') return null + if (selector === '[name="csrfmiddlewaretoken"]') + return mockInputToken + return null + }) + + const token = apiClient.getCSRFToken() + + expect(token).toBe("input-csrf-token") + }) + + test("should fallback to cookie for CSRF token", () => { + global.document.cookie = "csrftoken=cookie-csrf-token; other=value" + global.document.querySelector = jest.fn(() => null) + + const token = apiClient.getCSRFToken() + + expect(token).toBe("cookie-csrf-token") + }) + + test("should return empty string when no CSRF token found", () => { + // Mock document with no cookie and no DOM elements + const originalCookie = global.document.cookie + Object.defineProperty(global.document, "cookie", { + writable: true, + value: "", + }) + global.document.querySelector = jest.fn(() => null) + + const token = apiClient.getCSRFToken() + + expect(token).toBe("") + + // Restore cookie + Object.defineProperty(global.document, "cookie", { + writable: true, + value: originalCookie, + }) + }) + }) + + describe("Cookie Management", () => { + test.each([ + [ + "test-cookie=test-value; other-cookie=other-value", + "test-cookie", + "test-value", + ], + ["other-cookie=other-value", "test-cookie", null], + ["", "test-cookie", null], + [ + "test-cookie=test%20value%20encoded", + "test-cookie", + "test value encoded", + ], + ])( + "should handle cookie '%s' - get '%s' returns '%s'", + (cookieString, cookieName, expected) => { + Object.defineProperty(global.document, "cookie", { + writable: true, + value: cookieString, + }) + + const value = apiClient.getCookie(cookieName) + + expect(value).toBe(expected) + }, + ) + }) + + describe("API Requests", () => { + test("should make successful GET request", async () => { + mockFetchResolved(mockFetch) + + const result = await apiClient.request("/api/test") + + // Check that fetch was called with correct headers + expect(mockFetch).toHaveBeenCalled() + const callArgs = mockFetch.mock.calls[0] + expect(callArgs[1].headers["X-Requested-With"]).toBe( + "XMLHttpRequest", + ) + expect(result).toEqual({ success: true }) + }) + + test("should make POST request with CSRF token", async () => { + mockFetchResolved(mockFetch) + installCsrfMetaToken() + + await apiClient.request("/api/test", { + method: "POST", + body: JSON.stringify({ test: "data" }), + }) + + expect(mockFetch).toHaveBeenCalledWith("/api/test", { + method: "POST", + body: JSON.stringify({ test: "data" }), + headers: { + "X-Requested-With": "XMLHttpRequest", + "X-CSRFToken": "test-csrf-token", + }, + }) + }) + + test("should handle loading state", async () => { + mockFetchResolved(mockFetch) + + // Create a loading state manager to track loading + const mockLoadingState = { + setLoading: jest.fn(), + } + + await apiClient.request("/api/test", {}, mockLoadingState) + + // Verify loading state was set to true and then false + expect(mockLoadingState.setLoading).toHaveBeenCalledWith(true) + expect(mockLoadingState.setLoading).toHaveBeenCalledWith(false) + }) + + test("should handle non-JSON response", async () => { + mockFetch.mockResolvedValue( + createMockFetchResponse({ + contentType: "text/plain", + }), + ) + + const result = await apiClient.request("/api/test") + + expect(result).toBe("plain text") + }) + + test("should handle HTTP error responses", async () => { + mockFetch.mockResolvedValue( + createMockFetchResponse({ + ok: false, + status: 404, + jsonData: { error: "Resource not found" }, + }), + ) + + await expect(apiClient.request("/api/test")).rejects.toThrow() + }) + + test("should handle network errors", async () => { + mockFetch.mockRejectedValue(new Error("Network error")) + + await expect(apiClient.request("/api/test")).rejects.toThrow( + "Network error", + ) + }) + + test("should handle JSON parse errors", async () => { + mockFetch.mockResolvedValue( + createMockFetchResponse({ + jsonReject: new Error("Invalid JSON"), + }), + ) + + await expect(apiClient.request("/api/test")).rejects.toThrow( + "Invalid JSON", + ) + }) + }) + + describe("Convenience Methods", () => { + beforeEach(() => { + installMockWindowOrigin() + }) + + test("should make GET request", async () => { + mockFetchResolved(mockFetch) + + await apiClient.get("/api/test") + + // Verify fetch was called with correct URL and parameters + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:8000/api/test", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + "X-Requested-With": "XMLHttpRequest", + }), + }), + ) + }) + + test("should make GET request with query parameters", async () => { + mockFetchResolved(mockFetch) + + await apiClient.get("/api/test", { + param1: "value1", + param2: "value2", + }) + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining( + "/api/test?param1=value1¶m2=value2", + ), + expect.objectContaining({ + method: "GET", + }), + ) + }) + + test.each([ + ["POST", "post"], + ["PUT", "put"], + ["PATCH", "patch"], + ])( + "should make %s request with FormData and CSRF token", + async (method, methodName) => { + mockFetchResolved(mockFetch) + installCsrfMetaToken() + + await apiClient[methodName]("/api/test", { data: "test" }) + + // Verify request was made with FormData and CSRF token + expect(mockFetch).toHaveBeenCalled() + const callArgs = mockFetch.mock.calls[0] + expect(callArgs[0]).toBe("/api/test") + expect(callArgs[1].method).toBe(method) + expect(callArgs[1].body).toBeInstanceOf(FormData) + expect(callArgs[1].headers["X-CSRFToken"]).toBe( + "test-csrf-token", + ) + }, + ) + }) + + describe("Error Handling", () => { + test("should create APIError with correct properties", async () => { + mockFetch.mockResolvedValue( + createMockFetchResponse({ + ok: false, + status: 404, + jsonData: { error: "Resource not found" }, + }), + ) + + try { + await apiClient.request("/api/test") + // Should not reach here + expect(true).toBe(false) + } catch (error) { + // Check error properties + expect(error.name).toBe("APIError") + expect(error.message).toBe("HTTP 404: Not Found") + expect(error.status).toBe(404) + expect(error.data).toEqual({ error: "Resource not found" }) + } + }) + + test("should handle loading state errors", async () => { + mockFetch.mockRejectedValue(new Error("Network error")) + + const mockLoadingState = { + setLoading: jest.fn(), + } + + try { + await apiClient.request("/api/test", {}, mockLoadingState) + } catch (error) { + // Verify loading state was set to false even on error + expect(mockLoadingState.setLoading).toHaveBeenCalledWith(true) + expect(mockLoadingState.setLoading).toHaveBeenCalledWith(false) + } + }) + }) + + describe("URL Parameter Handling", () => { + beforeEach(() => { + installMockWindowOrigin() + }) + + test("should handle empty parameters", async () => { + mockFetchResolved(mockFetch) + + await apiClient.get("/api/test", {}) + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:8000/api/test", + expect.any(Object), + ) + }) + + test("should handle multiple parameters", async () => { + mockFetchResolved(mockFetch) + + await apiClient.get("/api/test", { + param1: "value1", + param2: "value2", + param3: "value3", + }) + + const url = mockFetch.mock.calls[0][0] + expect(url).toContain("param1=value1") + expect(url).toContain("param2=value2") + expect(url).toContain("param3=value3") + }) + + test("should encode special characters in parameters", async () => { + mockFetchResolved(mockFetch) + + await apiClient.get("/api/test", { + query: "test value & special=chars", + }) + + const url = mockFetch.mock.calls[0][0] + expect(url).toContain("test%20value") + expect(url).toContain("%26") + expect(url).toContain("%3D") + }) + + test("should filter out null and undefined parameters", async () => { + mockFetchResolved(mockFetch) + + await apiClient.get("/api/test", { + valid: "value", + nullParam: null, + undefinedParam: undefined, + }) + + const url = mockFetch.mock.calls[0][0] + expect(url).toContain("valid=value") + expect(url).not.toContain("nullParam") + expect(url).not.toContain("undefinedParam") + }) + }) + + describe("Content Type Detection", () => { + test.each([ + [null, "plain text"], + ["invalid-content-type", "plain text"], + ["text/plain", "plain text"], + ["application/json", { success: true }], + ])( + "should handle content-type '%s' correctly", + async (contentType, expectedResult) => { + mockFetch.mockResolvedValue( + createMockFetchResponse({ contentType }), + ) + + const result = await apiClient.request("/api/test") + + if (contentType?.includes("application/json")) { + expect(result).toEqual(expectedResult) + } else { + expect(result).toBe(expectedResult) + } + }, + ) + }) +}) diff --git a/gateway/sds_gateway/static/js/core/__tests__/DOMUtils.test.js b/gateway/sds_gateway/static/js/core/__tests__/DOMUtils.test.js index 82cb7f137..54cb07432 100644 --- a/gateway/sds_gateway/static/js/core/__tests__/DOMUtils.test.js +++ b/gateway/sds_gateway/static/js/core/__tests__/DOMUtils.test.js @@ -3,1253 +3,1309 @@ * Tests DOM manipulation utilities with API-based template rendering */ -import { DOMUtils } from "../DOMUtils.js"; +import { DOMUtils } from "../DOMUtils.js" /** Approximate Element.textContent from innerHTML for mock nodes (strip tags, decode entities). */ function mockTextContentFromHtml(html) { - if (html == null || html === "") return ""; - const noTags = String(html).replace(/<[^>]*>/g, ""); - return noTags - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/&/g, "&"); + if (html == null || html === "") return "" + const noTags = String(html).replace(/<[^>]*>/g, "") + return noTags + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") } describe("DOMUtils", () => { - let domUtils; - let mockAPIClient; - let mockContainer; - let mockToastContainer; - - beforeEach(() => { - // Reset mocks - jest.clearAllMocks(); - - // Create mock elements - mockContainer = { - id: "test-container", - innerHTML: "", - appendChild: jest.fn(), - classList: { - add: jest.fn(), - remove: jest.fn(), - contains: jest.fn(() => false), - }, - querySelector: jest.fn(), - querySelectorAll: jest.fn(() => []), - }; - Object.defineProperty(mockContainer, "textContent", { - get() { - return mockTextContentFromHtml(this.innerHTML); - }, - configurable: true, - }); - - mockToastContainer = { - id: "toast-container", - appendChild: jest.fn(), - }; - - // Mock APIClient - mockAPIClient = { - post: jest.fn().mockResolvedValue({ html: "
Test HTML
" }), - get: jest.fn(), - }; - - // Set up global mocks - global.window.APIClient = mockAPIClient; - global.document.getElementById = jest.fn((id) => { - if (id === "toast-container") return mockToastContainer; - if (id === "test-container") return mockContainer; - return null; - }); - global.document.querySelector = jest.fn((selector) => { - if (selector === "#test-container") return mockContainer; - return null; - }); - global.document.createElement = jest.fn((tag) => { - if (tag === "div") { - const child = { - id: "", - remove: jest.fn(), - addEventListener: jest.fn(), - }; - const el = { - tagName: "div", - id: "", - className: "", - textContent: "", - innerHTML: "", - classList: { - add: jest.fn(), - remove: jest.fn(), - }, - setAttribute: jest.fn(), - getAttribute: jest.fn(), - appendChild: jest.fn(), - addEventListener: jest.fn(), - get firstElementChild() { - const html = String(this.innerHTML || "").trim(); - return html ? child : null; - }, - }; - return el; - } - if (tag === "button") { - return { - tagName: "button", - type: "", - className: "", - setAttribute: jest.fn(), - getAttribute: jest.fn(), - }; - } - return null; - }); - - // Mock Bootstrap Toast - global.bootstrap = { - Toast: jest.fn().mockImplementation((element) => ({ - show: jest.fn(), - hide: jest.fn(), - element: element, - })), - }; - window.bootstrap = global.bootstrap; - - // Create DOMUtils instance - domUtils = new DOMUtils(); - }); - - describe("Basic DOM Manipulation", () => { - describe("show()", () => { - test("should show element by removing display-none and adding display-block", () => { - domUtils.show(mockContainer); - - expect(mockContainer.classList.remove).toHaveBeenCalledWith( - "display-none", - "d-none", - ); - expect(mockContainer.classList.add).toHaveBeenCalledWith( - "display-block", - ); - }); - - test("should show element with custom display class", () => { - domUtils.show(mockContainer, "d-flex"); - - expect(mockContainer.classList.remove).toHaveBeenCalledWith( - "display-none", - "d-none", - ); - expect(mockContainer.classList.add).toHaveBeenCalledWith("d-flex"); - }); - - test("should work with selector string", () => { - domUtils.show("#test-container"); - - expect(document.querySelector).toHaveBeenCalledWith("#test-container"); - expect(mockContainer.classList.add).toHaveBeenCalledWith( - "display-block", - ); - }); - - test("should handle missing element gracefully", () => { - console.warn = jest.fn(); - - domUtils.show("#nonexistent"); - - expect(console.warn).toHaveBeenCalledWith( - "Element not found for show():", - "#nonexistent", - ); - }); - }); - - describe("hide()", () => { - test("should hide element by removing display-block and adding display-none", () => { - domUtils.hide(mockContainer); - - expect(mockContainer.classList.remove).toHaveBeenCalledWith( - "display-block", - ); - expect(mockContainer.classList.add).toHaveBeenCalledWith( - "display-none", - ); - }); - - test("should hide element with custom display class", () => { - domUtils.hide(mockContainer, "d-flex"); - - expect(mockContainer.classList.remove).toHaveBeenCalledWith("d-flex"); - expect(mockContainer.classList.add).toHaveBeenCalledWith( - "display-none", - ); - }); - - test("should work with selector string", () => { - domUtils.hide("#test-container"); - - expect(document.querySelector).toHaveBeenCalledWith("#test-container"); - expect(mockContainer.classList.add).toHaveBeenCalledWith( - "display-none", - ); - }); - - test("should handle missing element gracefully", () => { - console.warn = jest.fn(); - - domUtils.hide("#nonexistent"); - - expect(console.warn).toHaveBeenCalledWith( - "Element not found for hide():", - "#nonexistent", - ); - }); - }); - }); - - describe("showMessage() toasts", () => { - let mockToastDiv; - let mockTempDiv; - - beforeEach(() => { - mockToastDiv = { - id: "", - addEventListener: jest.fn(), - }; - - mockTempDiv = { - innerHTML: "", - firstElementChild: mockToastDiv, - }; - - global.document.createElement = jest.fn((tag) => { - if (tag === "div") { - return mockTempDiv; - } - return { - tagName: tag, - id: "", - className: "", - textContent: "", - innerHTML: "", - classList: { - add: jest.fn(), - remove: jest.fn(), - }, - setAttribute: jest.fn(), - getAttribute: jest.fn(), - appendChild: jest.fn(), - addEventListener: jest.fn(), - }; - }); - - mockAPIClient.post.mockResolvedValue({ - html: '
Toast content
', - }); - }); - - test("showMessage posts message.html and shows toast", async () => { - await domUtils.showMessage("Test message", { - variant: "success", - placement: "toast", - presentation: "toast", - }); - - expect(mockAPIClient.post).toHaveBeenCalledWith( - "/users/render-html/", - { - template: "users/components/message.html", - context: { - message: "Test message", - type: "success", - presentation: "toast", - }, - }, - null, - true, - ); - expect(mockToastContainer.appendChild).toHaveBeenCalledWith(mockToastDiv); - expect(global.bootstrap.Toast).toHaveBeenCalledWith(mockToastDiv); - }); - - test("showMessage maps variants to template type", async () => { - const cases = [ - ["success", "success"], - ["danger", "error"], - ["warning", "warning"], - ["info", "info"], - ]; - - for (const [variant, expectedType] of cases) { - jest.clearAllMocks(); - await domUtils.showMessage("Hello", { - variant, - placement: "toast", - presentation: "toast", - }); - - expect(mockAPIClient.post).toHaveBeenCalledWith( - "/users/render-html/", - { - template: "users/components/message.html", - context: { - message: "Hello", - type: expectedType, - presentation: "toast", - }, - }, - null, - true, - ); - } - }); - - test("should handle missing toast container after render", async () => { - document.getElementById = jest.fn((id) => { - if (id === "toast-container") return null; - if (id === "test-container") return mockContainer; - return null; - }); - console.error = jest.fn(); - - const ok = await domUtils.showMessage("Test message", { - variant: "success", - placement: "toast", - presentation: "toast", - }); - - expect(ok).toBe(false); - expect(mockAPIClient.post).toHaveBeenCalled(); - expect(console.error).toHaveBeenCalled(); - expect(mockToastContainer.appendChild).not.toHaveBeenCalled(); - }); - - test("should handle missing HTML in API response", async () => { - mockAPIClient.post.mockResolvedValue({}); - console.error = jest.fn(); - - const ok = await domUtils.showMessage("Test message", { - variant: "success", - placement: "toast", - presentation: "toast", - }); - - expect(ok).toBe(false); - expect(console.error).toHaveBeenCalledWith( - "showMessage: no HTML from render-html", - ); - expect(mockToastContainer.appendChild).not.toHaveBeenCalled(); - }); - - test("should handle failed HTML parsing", async () => { - mockTempDiv.firstElementChild = null; - console.error = jest.fn(); - - const ok = await domUtils.showMessage("Test message", { - variant: "success", - placement: "toast", - presentation: "toast", - }); - - expect(ok).toBe(false); - expect(console.error).toHaveBeenCalledWith( - "showMessage: failed to parse message HTML", - ); - expect(mockToastContainer.appendChild).not.toHaveBeenCalled(); - }); - - test("should handle missing Bootstrap Toast", async () => { - global.bootstrap = null; - window.bootstrap = null; - console.error = jest.fn(); - - const ok = await domUtils.showMessage("Test message", { - variant: "success", - placement: "toast", - presentation: "toast", - }); - - expect(ok).toBe(false); - expect(console.error).toHaveBeenCalled(); - expect(mockToastContainer.appendChild).not.toHaveBeenCalled(); - }); - - test("should handle API errors gracefully", async () => { - mockAPIClient.post.mockRejectedValue(new Error("API error")); - console.error = jest.fn(); - - const ok = await domUtils.showMessage("Test message", { - variant: "success", - placement: "toast", - presentation: "toast", - }); - - expect(ok).toBe(false); - expect(console.error).toHaveBeenCalledWith( - "showMessage failed:", - expect.any(Error), - ); - expect(mockToastContainer.appendChild).not.toHaveBeenCalled(); - }); - }); - - describe("API-Based Rendering Methods", () => { - describe("showMessage() replace placement", () => { - let mockResultNode; - let mockWrapDiv; - - beforeEach(() => { - mockResultNode = { - id: "", - remove: jest.fn(), - addEventListener: jest.fn(), - }; - mockWrapDiv = { - innerHTML: "", - get firstElementChild() { - return mockResultNode; - }, - }; - global.document.createElement = jest.fn((tag) => { - if (tag === "div") return mockWrapDiv; - return null; - }); - }); - - test("posts message.html and replaces target content", async () => { - mockAPIClient.post.mockResolvedValue({ - html: 'Error message', - }); - - const result = await domUtils.showMessage("Error message", { - variant: "danger", - placement: "replace", - target: mockContainer, - presentation: "inline", - }); - - expect(mockAPIClient.post).toHaveBeenCalledWith( - "/users/render-html/", - { - template: "users/components/message.html", - context: { - message: "Error message", - type: "error", - presentation: "inline", - }, - }, - null, - true, - ); - expect(mockContainer.appendChild).toHaveBeenCalledWith(mockResultNode); - expect(result).toBe(true); - }); - - test.each([ - [ - "table", - "table", - 'Error', - { - message: "Error message", - type: "error", - presentation: "table", - colspan: 5, - }, - ], - [ - "alert", - "alert", - '
Error message
', - { - message: "Error message", - type: "error", - presentation: "alert", - }, - ], - ])( - "posts message.html with presentation %s", - async (_label, presentation, html, expectedContext) => { - mockAPIClient.post.mockResolvedValue({ html }); - - await domUtils.showMessage("Error message", { - variant: "danger", - placement: "replace", - target: mockContainer, - presentation, - templateContext: - presentation === "table" ? { colspan: 5 } : {}, - }); - - expect(mockAPIClient.post).toHaveBeenCalledWith( - "/users/render-html/", - { - template: "users/components/message.html", - context: expectedContext, - }, - null, - true, - ); - }, - ); - - test("returns false on API error without mutating container", async () => { - mockContainer.innerHTML = "

prior

"; - mockAPIClient.post.mockRejectedValue(new Error("API error")); - console.error = jest.fn(); - - const result = await domUtils.showMessage("Error message", { - variant: "danger", - placement: "replace", - target: mockContainer, - presentation: "table", - templateContext: { colspan: 5 }, - }); - - expect(mockContainer.innerHTML).toBe("

prior

"); - expect(result).toBe(false); - }); - - test("resolves target via selector string", async () => { - mockAPIClient.post.mockResolvedValue({ - html: 'x', - }); - - await domUtils.showMessage("Error message", { - variant: "danger", - placement: "replace", - target: "#test-container", - presentation: "inline", - }); - - expect(document.querySelector).toHaveBeenCalledWith("#test-container"); - expect(mockAPIClient.post).toHaveBeenCalled(); - }); - }); - - describe("renderLoading()", () => { - test.each([ - [ - "default options", - undefined, - undefined, - ' Loading...', - { - text: "Loading...", - format: "spinner", - size: "md", - color: "primary", - }, - ], - [ - "custom text", - "Please wait...", - undefined, - ' Please wait...', - { - text: "Please wait...", - format: "spinner", - size: "md", - color: "primary", - }, - ], - [ - "custom options", - "Loading...", - { size: "sm", color: "secondary" }, - '', - { - text: "Loading...", - format: "spinner", - size: "sm", - color: "secondary", - }, - ], - ])( - "should render loading with %s", - async (description, text, options, expectedHtml, expectedContext) => { - mockAPIClient.post.mockResolvedValue({ html: expectedHtml }); - - const result = await domUtils.renderLoading( - mockContainer, - text, - options, - ); - - expect(mockAPIClient.post).toHaveBeenCalledWith( - "/users/render-html/", - { - template: "users/components/loading.html", - context: expectedContext, - }, - null, - true, - ); - expect(mockContainer.innerHTML).toBe(expectedHtml); - expect(result).toBe(true); - }, - ); - }); - - describe("renderContent()", () => { - test.each([ - [ - { icon: "check", text: "Success" }, - ' Success', - { icon: "check", text: "Success" }, - ], - [ - { icon: "exclamation-circle", text: "Warning", color: "warning" }, - ' Warning', - { icon: "exclamation-circle", text: "Warning", color: "warning" }, - ], - ])( - "should render content with options", - async (options, expectedHtml, expectedContext) => { - mockAPIClient.post.mockResolvedValue({ html: expectedHtml }); - - const result = await domUtils.renderContent(mockContainer, options); - - expect(mockAPIClient.post).toHaveBeenCalledWith( - "/users/render-html/", - { - template: "users/components/content.html", - context: expectedContext, - }, - null, - true, - ); - expect(mockContainer.innerHTML).toBe(expectedHtml); - expect(result).toBe(true); - }, - ); - }); - - describe("renderTable()", () => { - test("should post text cells (kind text) to Django table_rows template", async () => { - const rows = [ - { - cells: [ - { kind: "text", value: "Cell 1" }, - { kind: "text", value: "Cell 2" }, - ], - }, - { - cells: [ - { kind: "text", value: "Cell 3" }, - { kind: "text", value: "Cell 4" }, - ], - }, - ]; - - mockAPIClient.post.mockResolvedValue({ - html: "Cell 1Cell 2Cell 3Cell 4", - }); - - const result = await domUtils.renderTable(mockContainer, rows); - - expect(mockAPIClient.post).toHaveBeenCalledWith( - "/users/render-html/", - { - template: "users/components/table_rows.html", - context: { - rows: rows, - empty_message: "No items found", - empty_colspan: 5, - }, - }, - null, - true, - ); - expect(result).toBe(true); - }); - - test("text cells: server escapes markup in value (mocked Django response)", async () => { - const rows = [ - { - cells: [ - { kind: "text", value: "Plain" }, - { kind: "text", value: "" }, - ], - }, - ]; - mockAPIClient.post.mockResolvedValue({ - html: "Plain<script>alert(1)</script>", - }); - - await domUtils.renderTable(mockContainer, rows); - - expect(mockContainer.innerHTML).not.toMatch(/", + }, + ], + }, + ] + mockAPIClient.post.mockResolvedValue({ + html: "Plain<script>alert(1)</script>", + }) + + await domUtils.renderTable(mockContainer, rows) + + expect(mockContainer.innerHTML).not.toMatch(/"] - expect(chipInput.chips).toEqual([]); - }); - }); + chipInput.renderChips() - describe("Paste Event", () => { - beforeEach(() => { - chipInput = new window.KeywordChipInput(mockInput, mockHiddenInput); - jest.useFakeTimers(); - }); + const chip = mockChipContainer.querySelector(".keyword-chip") + const span = chip.querySelector("span") + expect(span.innerHTML).not.toContain("", + ) - test("should render chips with correct structure", () => { - chipInput.chips = ["keyword1", "keyword2"]; - - chipInput.renderChips(); - - const chips = mockChipContainer.querySelectorAll(".keyword-chip"); - expect(chips.length).toBe(2); - - // Check chip structure - const firstChip = chips[0]; - expect(firstChip.textContent).toContain("keyword1"); - expect(firstChip.querySelector(".btn-close")).toBeTruthy(); - }); - - test("should escape HTML in chip text", () => { - chipInput.chips = [""]; - - chipInput.renderChips(); - - const chip = mockChipContainer.querySelector(".keyword-chip"); - const span = chip.querySelector("span"); - expect(span.innerHTML).not.toContain(""); - - expect(escaped).not.toContain("