diff --git a/.cursor/skills/new-javascript-manager/SKILL.md b/.cursor/skills/new-javascript-manager/SKILL.md new file mode 100644 index 00000000..2c370cfb --- /dev/null +++ b/.cursor/skills/new-javascript-manager/SKILL.md @@ -0,0 +1,182 @@ +--- +name: new-javascript-manager +description: Plans SDS gateway web UI features by decomposing user interactions into JavaScript manager methods and a shared implementation todo. Use when adding a new manager, action flow, or modal workflow under gateway/sds_gateway/static/js, or when the user asks to design or implement a new web UI feature interactively. +--- + +# New JavaScript manager (UX → methods → todo) + +## Goal + +Before writing code, **walk through the feature with the user** as a numbered user journey. Each step becomes one or more manager methods, DOM hooks, and (if needed) backend/API tasks. Deliver an agreed **markdown todo list** the agent and user follow for implementation. + +Do **not** start implementation until the todo list reflects the full flow and the user confirms (or explicitly says to proceed). + +## When to apply + +- New `*Manager.js` under `gateway/sds_gateway/static/js/` (especially `actions/`) +- Extending an existing manager with a new multi-step UI flow +- User says "new feature", "new action", "modal for…", or attaches this skill + +## Project conventions (mandatory) + +Follow `.cursor/rules/django_javascript_implementation.mdc`: + +- Logic in **separate `.js` files**; templates only **initialize** managers +- New classes in a **subfolder** of `js/` (e.g. `actions/`), not flat `js/` +- Dynamic HTML via Django **`components/`** + `RenderHTMLFragmentView` + `APIClient`; **no HTML strings in JS** +- Reuse `BaseManager`, `ModalManager`, `DOMUtils`, `APIClient`, existing controllers (e.g. `UserInputController`) before adding parallel code +- Ignore `deprecated/` + +Reference implementations: `ShareActionManager`, `PublishActionManager`, `DownloadActionManager` in `actions/`. + +## Collaborative workflow + +Copy this checklist and update it in the chat (or write `docs/features/-todo.md` only if the user asks for a file): + +```text +Planning progress: +- [ ] Name feature, entry point(s), and asset/page scope +- [ ] Draft numbered user journey (happy path) +- [ ] Add error/cancel/back paths per step +- [ ] Map each step → methods + state + DOM/API +- [ ] Identify reuse vs new JS/template/view/API +- [ ] User confirms todo list +- [ ] Implement in todo order +``` + +### Phase 1 — Discovery (with the user) + +Ask or infer, one topic at a time when unclear: + +1. **Where** does the user start? (page, row action, dropdown, keyboard) +2. **What** opens next? (modal, inline panel, new route) +3. **Permissions** — who can see/use the action? +4. **Persistence** — what API runs on confirm, and what UI updates after success? +5. **Configuration** — should behavior differ by asset type via config, not duplicated methods? + +Use `AskQuestion` when multiple valid UX choices exist. + +### Phase 2 — Numbered user journey + +Write steps in **present tense, user-visible behavior** (not implementation): + +```markdown +## User journey: [Feature name] + +1. User … +2. User … +… +``` + +Include for each step when relevant: + +- Visible UI change (modal open, disabled button, spinner) +- Validation / limits (max results, debounce, partial match rules) +- Default vs optional inputs +- Cancel / dismiss behavior + +### Phase 3 — Map steps to manager design + +For each journey step, extend this table (empty cells mean "none"): + +| Step | User action | UI state after | Manager method(s) | DOM / selectors | State on `this` | API / fragment | +|------|-------------|----------------|-------------------|-----------------|-----------------|----------------| +| 1 | … | … | `initialize…` / `setup…` | `#…`, `.…` | … | … | + +**Naming patterns** (match existing managers): + +| Responsibility | Typical names | +|----------------|---------------| +| Wire clicks/inputs once | `initializeEventListeners`, `setupModalEventHandlers`, `setupSearchInput` | +| Single control binding | `setupShareItem`, `setupRemoveUserButtons` | +| Event handler / submit | `handleShareItem`, `handlePermissionChange` | +| Async server work | `searchUsers`, `handleShareItem` (API + refresh) | +| Render/update UI | `renderChips`, `displayResults`, `updateSaveButtonState` | +| Small internal helpers | `_commitViewerSelection` (leading `_` if not part of public surface) | + +**Class shape:** + +- Extend `ModalManager` when the flow uses Bootstrap modals; otherwise `BaseManager` +- `constructor(config)` — store ids, permissions, debounce handles, pending change maps +- Call `initializeEventListeners()` from constructor +- Prefer **configuration-driven** branches over copy-paste per asset type + +### Phase 4 — Implementation todo (deliverable) + +After mapping, output this template filled in for the feature: + +```markdown +# [Feature name] — implementation todo + +## Summary + +[One paragraph: what the user can do and where] + +## User journey + +1. … +2. … + +## Manager: `[ClassName]` (`[path/to/Class].js`) + +- [ ] Create class extending `[BaseManager|ModalManager]` +- [ ] `constructor(config)` — … +- [ ] `initializeEventListeners()` — … +- [ ] Step N: `[methodName]` — … +- [ ] … + +## Templates & init + +- [ ] Page/partial: `[template]` — script tags + `new ClassName({…})` +- [ ] Modal/partial: `[partial]` — markup only, no inline logic +- [ ] Component fragment (if dynamic): `[component]` + view context + +## Backend (if needed) + +- [ ] Endpoint / view: … +- [ ] Permissions: … + +## Tests + +- [ ] `__tests__/[ClassName].test.js` — critical paths per journey step (see `.cursor/skills/jest-test-writing/SKILL.md`) + +## Done when + +- [ ] Happy path matches journey +- [ ] Errors toasts / disabled states documented in journey +- [ ] Page shows updated data after confirm (refresh, fragment, or DOM patch) +``` + +Mark items `- [x]` only as they ship. + +## Canonical example — Share + +Use this as the pattern for decomposition (reference: `actions/ShareActionManager.js`). + +| Step | User action | Methods / systems | +|------|-------------|-------------------| +| 1 | Opens asset dropdown | Page markup + existing list handlers (often outside share manager) | +| 2 | Chooses Share | Opens modal (`ModalManager` / Bootstrap) | +| 3 | Share modal visible | `setupModalEventHandlers` — resolve modal id, bind controls | +| 4 | Types name/email in search | `setupSearchInput` → `UserInputController`; debounce; `searchUsers`; `displayResults` / `displayError`; limits via API + dropdown UI | +| 5 | Picks a user from results | `selectUser`, `navigateDropdown`; may `checkUserInGroup` | +| 6 | Sets permission | `handlePermissionChange`, `handlePermissionLevelChange`, `updateDropdownMenu`; `pendingPermissionChanges` | +| 7 | Confirms | `setupShareItem` → `handleShareItem`; `updateSaveButtonState` | +| 8 | UI reflects new shares | API success → refresh chips/list, `clearSelections`, toasts; optional notify via `setupNotifyCheckbox` | + +When planning a **new** feature, mirror this granularity: search/debounce, pending vs committed state, confirm button gating, and post-success refresh are separate todo items. + +## Agent behavior + +1. **Collaborate first** — propose journey v1, ask user to correct missing steps +2. **One revision at a time** — update table + todo after user feedback +3. **Reuse explicitly** — name existing classes/methods to extend instead of duplicating +4. **Implement only after confirmation** — then work the todo top-to-bottom; say which item you are on +5. **Large refactors** — after implementation plan is stable, optional pass with `.cursor/skills/gateway-static-js-refactor/SKILL.md` + +## Anti-patterns + +- Skipping the journey and jumping straight to a new 500-line manager +- Putting business logic or HTML generation in Django templates beyond init +- One giant `handleEverything()` instead of one method per user step +- Duplicate search/modal patterns when `UserInputController` / `ModalManager` already apply diff --git a/gateway/sds_gateway/api_methods/helpers/capture_reindex_preview.py b/gateway/sds_gateway/api_methods/helpers/capture_reindex_preview.py new file mode 100644 index 00000000..4f525898 --- /dev/null +++ b/gateway/sds_gateway/api_methods/helpers/capture_reindex_preview.py @@ -0,0 +1,117 @@ +"""Discover files under a capture path that would change on capture update (reindex).""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING +from typing import Any +from typing import Literal + +from sds_gateway.api_methods.helpers.reconstruct_file_tree import ( + _get_list_of_capture_files, +) +from sds_gateway.api_methods.models import Capture +from sds_gateway.api_methods.models import CaptureType +from sds_gateway.api_methods.models import File +from sds_gateway.api_methods.utils.relationship_utils import get_capture_files +from sds_gateway.api_methods.utils.sds_files import sanitize_path_rel_to_user + +if TYPE_CHECKING: + from sds_gateway.users.models import User + +ReindexCandidateStatus = Literal["not_linked", "updated"] + + +def _normalize_directory(directory: str) -> str: + return str(directory).rstrip("/") + + +def resolve_capture_virtual_top_dir(capture: Capture, owner: User) -> Path | None: + """Virtual directory prefix used for file discovery (matches ingest_capture).""" + requested = sanitize_path_rel_to_user( + unsafe_path=capture.top_level_dir, + user=owner, + ) + if requested is None: + return None + top_level_dir = Path(requested) + user_file_prefix = f"/files/{owner.email!s}" + if not str(top_level_dir).startswith(user_file_prefix): + top_level_dir = Path(f"{user_file_prefix!s}{top_level_dir!s}") + return top_level_dir + + +def classify_reindex_candidates( + eligible_files: list[File], + linked_files: list[File], +) -> list[dict[str, Any]]: + """Compare eligible tree files to capture-linked files by path and checksum.""" + linked_by_path = { + (_normalize_directory(f.directory), f.name): f for f in linked_files + } + linked_ids = {f.uuid for f in linked_files} + + candidates: list[dict[str, Any]] = [] + seen_paths: set[tuple[str, str]] = set() + + for file_obj in eligible_files: + path_key = (_normalize_directory(file_obj.directory), file_obj.name) + if path_key in seen_paths: + continue + seen_paths.add(path_key) + + linked = linked_by_path.get(path_key) + if linked is None: + if file_obj.uuid not in linked_ids: + candidates.append(_serialize_candidate(file_obj, "not_linked")) + continue + + checksum_changed = ( + bool(linked.sum_blake3) + and bool(file_obj.sum_blake3) + and linked.sum_blake3 != file_obj.sum_blake3 + ) + identity_changed = linked.uuid != file_obj.uuid + if identity_changed or checksum_changed: + candidates.append(_serialize_candidate(file_obj, "updated")) + + return candidates + + +def _serialize_candidate( + file_obj: File, + status: ReindexCandidateStatus, +) -> dict[str, Any]: + return { + "uuid": str(file_obj.uuid), + "directory": file_obj.directory, + "name": file_obj.name, + "size": file_obj.size, + "status": status, + "sum_blake3": file_obj.sum_blake3 or "", + } + + +def get_capture_reindex_candidates(capture: Capture) -> list[dict[str, Any]]: + """Files under the capture tree not linked or replaced at the same path.""" + owner = capture.owner + if owner is None: + return [] + + virtual_top_dir = resolve_capture_virtual_top_dir(capture, owner) + if virtual_top_dir is None: + return [] + + cap_type = CaptureType(capture.capture_type) + eligible_qs = _get_list_of_capture_files( + capture_type=cap_type, + virtual_top_dir=virtual_top_dir, + owner=owner, + drf_channel=capture.channel if cap_type == CaptureType.DigitalRF else None, + rh_scan_group=capture.scan_group + if cap_type == CaptureType.RadioHound + else None, + ) + eligible = list(eligible_qs.order_by("directory", "name")) + linked = list(get_capture_files(capture)) + return classify_reindex_candidates(eligible, linked) diff --git a/gateway/sds_gateway/api_methods/tests/test_capture_reindex_preview.py b/gateway/sds_gateway/api_methods/tests/test_capture_reindex_preview.py new file mode 100644 index 00000000..01057497 --- /dev/null +++ b/gateway/sds_gateway/api_methods/tests/test_capture_reindex_preview.py @@ -0,0 +1,65 @@ +"""Tests for capture reindex candidate discovery.""" + +import pytest + +from sds_gateway.api_methods.helpers.capture_reindex_preview import ( + classify_reindex_candidates, +) +from sds_gateway.api_methods.tests.factories import CaptureFactory +from sds_gateway.api_methods.tests.factories import FileFactory +from sds_gateway.users.tests.factories import UserFactory + + +@pytest.mark.django_db +class TestClassifyReindexCandidates: + def test_not_linked_file(self) -> None: + user = UserFactory() + capture = CaptureFactory(owner=user) + directory = f"/files/{user.email}/cap_a" + linked = FileFactory(owner=user, directory=directory, name="linked.h5") + linked.captures.add(capture) + unlinked = FileFactory( + owner=user, + directory=directory, + name="new_rf.h5", + ) + result = classify_reindex_candidates([unlinked], [linked]) + assert len(result) == 1 + assert result[0]["status"] == "not_linked" + assert result[0]["uuid"] == str(unlinked.uuid) + + def test_updated_checksum_same_path(self) -> None: + user = UserFactory() + capture = CaptureFactory(owner=user) + directory = f"/files/{user.email}/cap_a" + old = FileFactory( + owner=user, + directory=directory, + name="rf@1.h5", + sum_blake3="aaa", + ) + old.captures.add(capture) + new = FileFactory( + owner=user, + directory=directory, + name="rf@1.h5", + sum_blake3="bbb", + ) + result = classify_reindex_candidates([new], [old]) + assert len(result) == 1 + assert result[0]["status"] == "updated" + assert result[0]["uuid"] == str(new.uuid) + + def test_unchanged_linked_file_excluded(self) -> None: + user = UserFactory() + capture = CaptureFactory(owner=user) + directory = f"/files/{user.email}/cap_a" + linked = FileFactory( + owner=user, + directory=directory, + name="rf@1.h5", + sum_blake3="same", + ) + linked.captures.add(capture) + result = classify_reindex_candidates([linked], [linked]) + assert result == [] diff --git a/gateway/sds_gateway/api_methods/views/capture_endpoints.py b/gateway/sds_gateway/api_methods/views/capture_endpoints.py index aad1708a..94f23808 100644 --- a/gateway/sds_gateway/api_methods/views/capture_endpoints.py +++ b/gateway/sds_gateway/api_methods/views/capture_endpoints.py @@ -2,6 +2,7 @@ import json import re import tempfile +import threading import uuid from pathlib import Path from typing import Any @@ -352,24 +353,41 @@ def _trigger_post_processing(self, capture: Capture) -> None: ) try: - # Use the Celery task for post-processing to ensure proper async execution - # Launch the visualization processing task asynchronously processing_config = { ProcessingType.Waterfall.value: {}, ProcessingType.Spectrogram.value: {}, } + capture_uuid = str(capture.uuid) - result = start_capture_post_processing.delay( # pyright: ignore[reportFunctionMemberAccess] - str(capture.uuid), processing_config - ) - log.info( - f"Launched visualization processing task for capture {capture.uuid}, " - f"task_id: {result.id}" + def run_post_processing_task() -> None: + try: + result = start_capture_post_processing.delay( # pyright: ignore[reportFunctionMemberAccess] + capture_uuid, processing_config + ) + log.info( + f"Launched visualization processing task for capture " + f"{capture_uuid}, task_id: {result.id}" + ) + except Exception as e: # noqa: BLE001 + log.error( + f"Failed to launch visualization processing task for " + f"capture {capture_uuid}: {e}" + ) + + # on_commit runs before the HTTP response is flushed; run the Celery + # enqueue (and eager-mode task body) off the request thread so reindex + # PUT returns after ingest/indexing only. + transaction.on_commit( + lambda: threading.Thread( + target=run_post_processing_task, + daemon=True, + name=f"postproc-{capture_uuid}", + ).start() ) except Exception as e: # noqa: BLE001 log.error( - f"Failed to launch visualization processing task for capture " + f"Failed to schedule visualization processing for capture " f"{capture.uuid}: {e}" ) diff --git a/gateway/sds_gateway/api_methods/views/dataset_endpoints.py b/gateway/sds_gateway/api_methods/views/dataset_endpoints.py index c0d4c93c..0785ece8 100644 --- a/gateway/sds_gateway/api_methods/views/dataset_endpoints.py +++ b/gateway/sds_gateway/api_methods/views/dataset_endpoints.py @@ -9,6 +9,7 @@ from drf_spectacular.utils import OpenApiResponse from drf_spectacular.utils import extend_schema from rest_framework import status +from rest_framework.authentication import SessionAuthentication from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -55,7 +56,7 @@ def _truthy_query_param(raw: str | None) -> bool: class DatasetViewSet(ViewSet): - authentication_classes = [APIKeyAuthentication] + authentication_classes = [SessionAuthentication, APIKeyAuthentication] permission_classes = [IsAuthenticated] def _get_file_objects( diff --git a/gateway/sds_gateway/static/js/actions/AssetDeletionManager.js b/gateway/sds_gateway/static/js/actions/AssetDeletionManager.js new file mode 100644 index 00000000..1995453f --- /dev/null +++ b/gateway/sds_gateway/static/js/actions/AssetDeletionManager.js @@ -0,0 +1,179 @@ +/** + * Confirm and delete captures or datasets via the assets API. + */ +class AssetDeletionManager extends ModalManager { + constructor() { + super() + this.modalId = "deleteAssetModal" + this.modalEl = document.getElementById(this.modalId) + if (!this.modalEl) return + + this.typeLabelEl = document.getElementById("delete-asset-type-label") + this.typeLabelSharedEl = document.getElementById( + "delete-asset-type-label-shared", + ) + this.titleDeletableEl = document.getElementById( + "delete-asset-title-deletable", + ) + this.titleSharedEl = document.getElementById( + "delete-asset-title-shared", + ) + this.nameEl = document.getElementById("delete-asset-name") + this.nameSharedEl = document.getElementById("delete-asset-name-shared") + this.deletableBodyEl = document.getElementById( + "delete-asset-body-deletable", + ) + this.sharedBodyEl = document.getElementById("delete-asset-body-shared") + this.messageEl = document.getElementById("delete-asset-message") + this.confirmBtn = document.getElementById("delete-asset-confirm-btn") + + this.assetType = null + this.assetUuid = null + this.assetName = null + this.assetIsShared = false + + this.initializeEventListeners() + } + + initializeEventListeners() { + document.addEventListener("click", (e) => { + const btn = e.target.closest(".delete-asset-btn") + if (!btn) return + e.preventDefault() + e.stopPropagation() + const assetType = btn.getAttribute("data-asset-type") + const assetUuid = btn.getAttribute("data-asset-uuid") + if (!assetType || !assetUuid) return + this.openForAsset( + assetType, + assetUuid, + btn.getAttribute("data-asset-name") || "", + btn.getAttribute("data-asset-shared") === "true", + ) + }) + + if (!this.modalEl) return + + this.modalEl.addEventListener("show.bs.modal", () => { + this.clearInlineMessage() + this.applySharedState() + }) + + if (this.confirmBtn) { + this.confirmBtn.addEventListener( + "click", + () => void this.confirmDeletion(), + ) + } + } + + openForAsset(assetType, assetUuid, assetName, assetIsShared = false) { + this.assetType = assetType + this.assetUuid = assetUuid + this.assetName = assetName || "this asset" + this.assetIsShared = Boolean(assetIsShared) + + const typeLabel = + assetType === "dataset" + ? "dataset" + : assetType === "capture" + ? "capture" + : "asset" + if (this.typeLabelEl) { + this.typeLabelEl.textContent = typeLabel + } + if (this.typeLabelSharedEl) { + this.typeLabelSharedEl.textContent = typeLabel + } + const displayName = this.assetName + if (this.nameEl) { + this.nameEl.textContent = displayName + } + if (this.nameSharedEl) { + this.nameSharedEl.textContent = displayName + } + + this.applySharedState() + this.openModal(this.modalId) + } + + applySharedState() { + const isShared = this.assetIsShared + window.DOMUtils?.toggleHidden(this.deletableBodyEl, isShared) + window.DOMUtils?.toggleHidden(this.sharedBodyEl, !isShared) + window.DOMUtils?.toggleHidden(this.confirmBtn, isShared) + window.DOMUtils?.toggleHidden(this.titleDeletableEl, isShared) + window.DOMUtils?.toggleHidden(this.titleSharedEl, !isShared) + + if (this.confirmBtn) { + this.confirmBtn.disabled = isShared + } + } + + buildDeleteUrl(assetType, assetUuid) { + const plural = + assetType === "dataset" + ? "datasets" + : assetType === "capture" + ? "captures" + : `${assetType}s` + return `/api/v1/assets/${plural}/${assetUuid}/` + } + + clearInlineMessage() { + window.DOMUtils?.toggleHidden(this.messageEl, true) + if (this.messageEl) this.messageEl.textContent = "" + } + + showInlineMessage(text) { + if (!this.messageEl) return + this.messageEl.textContent = text + window.DOMUtils?.toggleHidden(this.messageEl, false) + } + + async confirmDeletion() { + if (this.assetIsShared) return + + const { assetType, assetUuid } = this + if (!assetType || !assetUuid) return + + if (this.confirmBtn) this.confirmBtn.disabled = true + this.clearInlineMessage() + + try { + await window.APIClient.delete( + this.buildDeleteUrl(assetType, assetUuid), + ) + + const label = + assetType === "dataset" + ? "Dataset" + : assetType === "capture" + ? "Capture" + : "Asset" + this.closeModalWithToast( + `${label} deleted successfully.`, + "success", + ModalManager.refreshListTableFromQueryString, + ) + } catch (err) { + console.error(err) + const detail = + err?.data?.detail || + err?.message || + "Could not delete this asset." + this.showInlineMessage( + typeof detail === "string" + ? detail + : "Could not delete this asset.", + ) + if (this.confirmBtn) this.confirmBtn.disabled = false + } + } +} + +window.AssetDeletionManager = AssetDeletionManager + +if (typeof module !== "undefined" && module.exports) { + module.exports = { AssetDeletionManager } +} diff --git a/gateway/sds_gateway/static/js/actions/CaptureReindexingManager.js b/gateway/sds_gateway/static/js/actions/CaptureReindexingManager.js new file mode 100644 index 00000000..8c88df38 --- /dev/null +++ b/gateway/sds_gateway/static/js/actions/CaptureReindexingManager.js @@ -0,0 +1,229 @@ +/** + * Capture reindex: preview pending files under top_level_dir, confirm via PUT update. + */ +class CaptureReindexingManager extends ModalManager { + constructor() { + super() + this.modalId = "reindexCaptureModal" + this.modalEl = document.getElementById(this.modalId) + if (!this.modalEl) return + + this.previewUrlTemplate = this.modalEl.getAttribute( + "data-preview-url-template", + ) + this.currentCaptureUuid = null + this.currentCaptureName = null + this.currentTopLevelDir = null + + this.nameEl = document.getElementById("reindex-capture-name") + this.pathEl = document.getElementById("reindex-top-level-dir") + this.loadingEl = document.getElementById("reindex-loading") + this.emptyHintEl = document.getElementById("reindex-empty-hint") + this.pendingSectionEl = document.getElementById( + "reindex-pending-section", + ) + this.pendingTbody = document.getElementById("reindex-pending-tbody") + this.progressEl = document.getElementById("reindex-progress") + this.messageEl = document.getElementById("reindex-message") + this.confirmBtn = document.getElementById("reindex-confirm-btn") + + this.initializeEventListeners() + } + + initializeEventListeners() { + document.addEventListener("click", (e) => { + const btn = e.target.closest(".reindex-capture-btn") + if (!btn) return + e.preventDefault() + e.stopPropagation() + const uuid = btn.getAttribute("data-capture-uuid") + if (!uuid) return + this.openForCapture( + uuid, + btn.getAttribute("data-capture-name") || "Capture", + btn.getAttribute("data-top-level-dir") || "", + ) + }) + + if (!this.modalEl) return + + this.modalEl.addEventListener("show.bs.modal", () => { + this.resetUi() + if (this.nameEl) { + this.nameEl.textContent = this.currentCaptureName || "Capture" + } + if (this.pathEl) { + this.pathEl.textContent = this.currentTopLevelDir || "—" + } + void this.loadPendingChanges() + }) + + if (this.confirmBtn) { + this.confirmBtn.addEventListener( + "click", + () => void this.confirmReindex(), + ) + } + } + + openForCapture(uuid, name, topLevelDir) { + this.currentCaptureUuid = uuid + this.currentCaptureName = name + this.currentTopLevelDir = topLevelDir + this.openModal(this.modalId) + } + + resetUi() { + this.clearInlineMessage() + this.setPreviewLoading(false) + this.setReindexProgress(false) + window.DOMUtils?.toggleHidden(this.emptyHintEl, true) + window.DOMUtils?.toggleHidden(this.pendingSectionEl, true) + if (this.pendingTbody) this.pendingTbody.innerHTML = "" + if (this.confirmBtn) { + this.confirmBtn.disabled = false + this.confirmBtn.textContent = "Reindex capture" + } + } + + clearInlineMessage() { + window.DOMUtils?.toggleHidden(this.messageEl, true) + if (this.messageEl) this.messageEl.textContent = "" + } + + showInlineMessage(text, variant = "danger") { + if (!this.messageEl) return + const alertType = + variant === "error" || variant === "danger" ? "danger" : variant + this.messageEl.className = `alert alert-${alertType} mt-3` + this.messageEl.textContent = text + window.DOMUtils?.toggleHidden(this.messageEl, false) + } + + async setPreviewLoading(visible) { + if (!this.loadingEl || !window.DOMUtils) return + window.DOMUtils.toggleHidden(this.loadingEl, !visible) + if (!visible) { + this.loadingEl.innerHTML = "" + return + } + await window.DOMUtils.renderLoading( + this.loadingEl, + "Checking for new or updated files…", + { + format: "block", + size: "sm", + }, + ) + } + + async setReindexProgress(visible, text = "Reindexing capture…") { + if (!this.progressEl || !window.DOMUtils) return + window.DOMUtils.toggleHidden(this.progressEl, !visible) + if (!visible) { + this.progressEl.innerHTML = "" + return + } + await window.DOMUtils.renderProgress(this.progressEl, text) + } + + buildPreviewUrl(captureUuid) { + if (!this.previewUrlTemplate) return null + return this.previewUrlTemplate.replace( + "00000000-0000-0000-0000-000000000000", + captureUuid, + ) + } + + async loadPendingChanges() { + const uuid = this.currentCaptureUuid + const url = uuid ? this.buildPreviewUrl(uuid) : null + if (!url) { + this.showInlineMessage("Preview URL is not configured.", "danger") + return + } + + await this.setPreviewLoading(true) + try { + const data = await window.APIClient.get(url) + this.renderPendingList(data?.pending_files || []) + } catch (err) { + console.error(err) + const detail = + err?.data?.detail || + err?.message || + "Could not load pending file changes." + this.showInlineMessage(detail, "danger") + } finally { + await this.setPreviewLoading(false) + } + } + + renderPendingList(pendingFiles) { + const hasPending = pendingFiles.length > 0 + window.DOMUtils?.toggleHidden(this.emptyHintEl, hasPending) + window.DOMUtils?.toggleHidden(this.pendingSectionEl, !hasPending) + if (!this.pendingTbody || !hasPending) return + + this.pendingTbody.innerHTML = "" + const formatSize = window.DOMUtils?.formatFileSize?.bind( + window.DOMUtils, + ) + for (const row of pendingFiles) { + const tr = document.createElement("tr") + const pathTd = document.createElement("td") + pathTd.className = "small font-monospace text-break" + const dir = String(row.directory || "").replace(/\/$/, "") + pathTd.textContent = dir ? `${dir}/${row.name}` : row.name + + const statusTd = document.createElement("td") + statusTd.className = `small ${row.status === "updated" ? "text-warning" : "text-primary"}` + statusTd.textContent = + row.status === "updated" ? "Updated" : "Not linked" + + const sizeTd = document.createElement("td") + sizeTd.className = "small text-end" + const size = Number(row.size) + sizeTd.textContent = + formatSize && Number.isFinite(size) && size > 0 + ? formatSize(size) + : "—" + + tr.append(pathTd, statusTd, sizeTd) + this.pendingTbody.appendChild(tr) + } + } + + async confirmReindex() { + const uuid = this.currentCaptureUuid + if (!uuid) return + + if (this.confirmBtn) this.confirmBtn.disabled = true + this.clearInlineMessage() + await this.setReindexProgress(true) + + try { + await window.APIClient.put(`/api/v1/assets/captures/${uuid}/`, {}) + + await this.setReindexProgress(false) + this.closeModalWithToast( + "Capture reindexed successfully. Visualizations will update in the background.", + "success", + ModalManager.refreshListTableFromQueryString, + ) + } catch (err) { + console.error(err) + await this.setReindexProgress(false) + const detail = + err?.data?.detail || err?.message || "Reindex failed." + this.showInlineMessage(detail, "danger") + if (this.confirmBtn) this.confirmBtn.disabled = false + } + } +} + +window.CaptureReindexingManager = CaptureReindexingManager + +if (typeof module !== "undefined" && module.exports) { + module.exports = { CaptureReindexingManager } +} diff --git a/gateway/sds_gateway/static/js/actions/__tests__/AssetDeletionManager.test.js b/gateway/sds_gateway/static/js/actions/__tests__/AssetDeletionManager.test.js new file mode 100644 index 00000000..eccd1f52 --- /dev/null +++ b/gateway/sds_gateway/static/js/actions/__tests__/AssetDeletionManager.test.js @@ -0,0 +1,85 @@ +/** + * Jest tests for AssetDeletionManager + */ + +import { AssetDeletionManager } from "../AssetDeletionManager.js" + +function installDeleteModalDom() { + document.body.innerHTML = ` +
+ + + + +
+ +
+
+
+ +
+ +
+ ` +} + +describe("AssetDeletionManager", () => { + beforeEach(() => { + installDeleteModalDom() + window.DOMUtils = { toggleHidden: jest.fn() } + window.APIClient = { delete: jest.fn().mockResolvedValue("") } + window.bootstrap = { + Modal: jest.fn(() => ({ show: jest.fn(), hide: jest.fn() })), + } + }) + + test("buildDeleteUrl for capture and dataset", () => { + const mgr = new AssetDeletionManager() + expect(mgr.buildDeleteUrl("capture", "abc")).toBe( + "/api/v1/assets/captures/abc/", + ) + expect(mgr.buildDeleteUrl("dataset", "def")).toBe( + "/api/v1/assets/datasets/def/", + ) + }) + + test("confirmDeletion calls APIClient.delete", async () => { + const mgr = new AssetDeletionManager() + mgr.assetType = "capture" + mgr.assetUuid = "uuid-1" + mgr.showToast = jest.fn() + mgr.closeModal = jest.fn() + + await mgr.confirmDeletion() + + expect(window.APIClient.delete).toHaveBeenCalledWith( + "/api/v1/assets/captures/uuid-1/", + ) + }) + + test("shared capture shows warning and skips delete API", async () => { + window.bootstrap = { + Modal: Object.assign( + jest.fn(() => ({ show: jest.fn(), hide: jest.fn() })), + { + getInstance: jest.fn(() => null), + }, + ), + } + const mgr = new AssetDeletionManager() + mgr.assetType = "capture" + mgr.assetUuid = "uuid-2" + mgr.assetName = "My Capture" + mgr.assetIsShared = true + mgr.applySharedState() + + expect(mgr.assetIsShared).toBe(true) + expect(window.DOMUtils.toggleHidden).toHaveBeenCalledWith( + mgr.confirmBtn, + true, + ) + + await mgr.confirmDeletion() + expect(window.APIClient.delete).not.toHaveBeenCalled() + }) +}) diff --git a/gateway/sds_gateway/static/js/actions/__tests__/CaptureReindexingManager.test.js b/gateway/sds_gateway/static/js/actions/__tests__/CaptureReindexingManager.test.js new file mode 100644 index 00000000..e29f69bc --- /dev/null +++ b/gateway/sds_gateway/static/js/actions/__tests__/CaptureReindexingManager.test.js @@ -0,0 +1,85 @@ +/** + * Jest tests for CaptureReindexingManager + */ + +import { CaptureReindexingManager } from "../CaptureReindexingManager.js" + +function installReindexModalDom() { + document.body.innerHTML = ` + +
+ + +
+
+
+ +
+
+ +
+ ` +} + +describe("CaptureReindexingManager", () => { + beforeEach(() => { + jest.clearAllMocks() + installReindexModalDom() + window.DOMUtils = { + toggleHidden: jest.fn((el, hidden) => { + el?.classList?.toggle("d-none", hidden) + }), + renderLoading: jest.fn().mockResolvedValue(true), + renderProgress: jest.fn().mockResolvedValue(true), + formatFileSize: jest.fn((n) => `${n} bytes`), + } + window.APIClient = { + get: jest.fn(), + put: jest.fn().mockResolvedValue({}), + getCSRFToken: jest.fn(() => "csrf"), + } + window.listRefreshManager = { + loadTable: jest.fn().mockResolvedValue(""), + } + window.bootstrap = { + Modal: jest.fn().mockImplementation(() => ({ + show: jest.fn(), + hide: jest.fn(), + })), + } + }) + + test("buildPreviewUrl substitutes capture uuid", () => { + const mgr = new CaptureReindexingManager() + expect(mgr.buildPreviewUrl("abc-123")).toBe( + "/users/captures/abc-123/reindex-preview/", + ) + }) + + test("renderPendingList shows empty hint when no pending files", () => { + const mgr = new CaptureReindexingManager() + mgr.renderPendingList([]) + expect(window.DOMUtils.toggleHidden).toHaveBeenCalledWith( + document.getElementById("reindex-empty-hint"), + false, + ) + }) + + test("confirmReindex PUTs capture update via APIClient", async () => { + const mgr = new CaptureReindexingManager() + mgr.currentCaptureUuid = "cap-uuid-9" + mgr.modalEl = document.getElementById("reindexCaptureModal") + mgr.showToast = jest.fn() + + await mgr.confirmReindex() + + expect(window.APIClient.put).toHaveBeenCalledWith( + "/api/v1/assets/captures/cap-uuid-9/", + {}, + ) + }) +}) diff --git a/gateway/sds_gateway/static/js/core/APIClient.js b/gateway/sds_gateway/static/js/core/APIClient.js index 077bd223..a70f6d9c 100644 --- a/gateway/sds_gateway/static/js/core/APIClient.js +++ b/gateway/sds_gateway/static/js/core/APIClient.js @@ -222,6 +222,23 @@ class APIClient { loadingState, ) } + + /** + * Make DELETE request + * @param {string} url - Request URL + * @param {Object} data - Request data + * @param {Object} loadingState - Loading state management + * @returns {Promise} Response data + */ + async delete(url, data = {}, loadingState = null) { + const formData = this._formDataFromObject(data) + + return this.request( + url, + { method: "DELETE", body: formData }, + loadingState, + ) + } } /** diff --git a/gateway/sds_gateway/static/js/core/DOMUtils.js b/gateway/sds_gateway/static/js/core/DOMUtils.js index 637d4abb..acc5d009 100644 --- a/gateway/sds_gateway/static/js/core/DOMUtils.js +++ b/gateway/sds_gateway/static/js/core/DOMUtils.js @@ -7,6 +7,7 @@ * * Available methods: * - formatFileSize(bytes) - Format file size + * - toggleHidden(element, hidden) - Toggle Bootstrap d-none on an element * - show(element, displayClass) - Show element with CSS class * - hide(element, displayClass) - Hide element with CSS class * - showMessage(message, opts) - User-visible messages (toast / inline) via Django template @@ -41,6 +42,30 @@ class DOMUtils { return `${i === 0 ? v : v.toFixed(2)} ${units[i]}` } + /** + * Show or hide an element via Bootstrap d-none. + * @param {Element|string|null} element + * @param {boolean} hidden + */ + toggleHidden(element, hidden) { + const el = + typeof element === "string" + ? document.querySelector(element) + : element + if (!el) return + el.classList.toggle("d-none", Boolean(hidden)) + } + + /** + * Indeterminate progress bar via loading.html (format progress). + * @param {Element|string} container + * @param {string} [text] + * @returns {Promise} + */ + async renderProgress(container, text = "Working…") { + return this.renderLoading(container, text, { format: "progress" }) + } + /** * Show element using CSS classes * @param {Element|string} element - Element or selector to show diff --git a/gateway/sds_gateway/static/js/core/ModalManager.js b/gateway/sds_gateway/static/js/core/ModalManager.js index 68495318..ec4072d3 100644 --- a/gateway/sds_gateway/static/js/core/ModalManager.js +++ b/gateway/sds_gateway/static/js/core/ModalManager.js @@ -74,7 +74,59 @@ class ModalManager extends BaseManager { const element = document.getElementById(modalId) if (!element || !window.bootstrap?.Modal) return const inst = bootstrap.Modal.getInstance(element) - if (inst) inst.hide() + if (!inst) return + try { + inst.hide() + } catch (err) { + console.warn("Modal hide failed, forcing cleanup:", err) + element.classList.remove("show") + element.setAttribute("aria-hidden", "true") + element.removeAttribute("aria-modal") + element.style.display = "none" + document.body.classList.remove("modal-open") + document.body.style.removeProperty("overflow") + document.body.style.removeProperty("padding-right") + for (const backdrop of document.querySelectorAll( + ".modal-backdrop", + )) { + backdrop.remove() + } + try { + inst.dispose() + } catch (_) { + /* ignore */ + } + } + } + + /** Reload list table using current URL query params (capture/dataset list pages). */ + static refreshListTableFromQueryString() { + if (!window.listRefreshManager?.loadTable) return + const params = Object.fromEntries( + new URLSearchParams(window.location.search), + ) + void window.listRefreshManager.loadTable(params, { + showLoading: false, + }) + } + + closeModalWithToast(msg, alertType, onAfterClose) { + const modalEl = document.getElementById(this.modalId) + if (modalEl) { + this.modalEl = modalEl + } + const afterClose = () => { + this.showToast(msg, alertType) + onAfterClose?.() + } + if (modalEl) { + modalEl.addEventListener("hidden.bs.modal", afterClose, { + once: true, + }) + this.closeModal(this.modalId) + } else { + afterClose() + } } /** diff --git a/gateway/sds_gateway/static/js/search/KeywordChipInput.js b/gateway/sds_gateway/static/js/search/KeywordChipInput.js index 3e0c3f10..29011356 100644 --- a/gateway/sds_gateway/static/js/search/KeywordChipInput.js +++ b/gateway/sds_gateway/static/js/search/KeywordChipInput.js @@ -115,17 +115,21 @@ class KeywordChipInput { const chip = document.createElement("span") chip.className = "keyword-chip badge bg-secondary d-inline-flex align-items-center gap-1 me-1" - chip.innerHTML = ` - ${this.escapeHtml(keyword)} - - ` + + const labelSpan = document.createElement("span") + labelSpan.textContent = keyword + + const removeBtn = document.createElement("button") + removeBtn.type = "button" + removeBtn.className = "btn-close btn-close-white" + removeBtn.style.fontSize = "0.65em" + removeBtn.setAttribute("aria-label", `Remove ${keyword}`) + removeBtn.dataset.keyword = keyword + + chip.appendChild(labelSpan) + chip.appendChild(removeBtn) // Add remove handler - use keyword instead of index to avoid index issues - const removeBtn = chip.querySelector(".btn-close") removeBtn.addEventListener("click", (e) => { e.stopPropagation() const keywordToRemove = removeBtn.getAttribute("data-keyword") @@ -141,12 +145,6 @@ class KeywordChipInput { this.hiddenInput.value = this.chips.join(",") } - escapeHtml(text) { - const div = document.createElement("div") - div.textContent = text - return div.innerHTML - } - getKeywords() { return this.chips } diff --git a/gateway/sds_gateway/static/js/search/__tests__/KeywordChipInput.test.js b/gateway/sds_gateway/static/js/search/__tests__/KeywordChipInput.test.js index c0a01867..44e8d533 100644 --- a/gateway/sds_gateway/static/js/search/__tests__/KeywordChipInput.test.js +++ b/gateway/sds_gateway/static/js/search/__tests__/KeywordChipInput.test.js @@ -432,15 +432,6 @@ describe("KeywordChipInput", () => { expect(mockInput.value).toBe("") expect(mockHiddenInput.value).toBe("") }) - - test("should escape HTML correctly", () => { - const escaped = chipInput.escapeHtml( - "", - ) - - expect(escaped).not.toContain(" + + @@ -428,6 +432,14 @@

} } + if (window.CaptureReindexingManager && document.getElementById("reindexCaptureModal")) { + window.captureReindexingManager = new window.CaptureReindexingManager(); + } + + if (window.AssetDeletionManager && document.getElementById("deleteAssetModal")) { + window.assetDeletionManager = new window.AssetDeletionManager(); + } + const downloadAlert = sessionStorage.getItem('captureDownloadAlert'); if (downloadAlert) { const alertData = JSON.parse(downloadAlert); diff --git a/gateway/sds_gateway/templates/users/components/loading.html b/gateway/sds_gateway/templates/users/components/loading.html index 504e8325..1fe83823 100644 --- a/gateway/sds_gateway/templates/users/components/loading.html +++ b/gateway/sds_gateway/templates/users/components/loading.html @@ -3,7 +3,7 @@ Renders loading states in various formats: spinner, modal, block Context: - text: Loading message text (default: "Loading...") - - format: 'spinner' | 'modal' | 'block' (default: 'spinner') + - format: 'spinner' | 'modal' | 'block' | 'progress' (default: 'spinner') - size: 'sm' | 'md' | 'lg' for spinner size (default: 'md') - color: Bootstrap color class (default: 'primary') {% endcomment %} @@ -25,6 +25,16 @@ {% if text %}

{{ text }}

{% endif %} +{% elif format == 'progress' %} + {# Indeterminate progress (long-running operations) #} +
+
+
+ {% if text %}

{{ text }}

{% endif %} {% else %} {# Inline spinner (default) #} Datasets

{% include "users/components/dataset_list_modals.html" with page_obj=page_obj %}
+ {% include "users/partials/delete_asset_modal.html" %} {% endblock content %} {% block javascript %} {# djlint:off #} @@ -57,6 +58,7 @@

Datasets

+