From 9855d800295608f1c9976a1e835a4fe189cf8183 Mon Sep 17 00:00:00 2001 From: klpoland Date: Wed, 3 Jun 2026 14:07:46 -0400 Subject: [PATCH 1/4] add asset deletion and capture reindexing managers, templates, skill for walking through new manager creation --- .../skills/new-javascript-manager/SKILL.md | 175 ++ .../helpers/capture_reindex_preview.py | 112 ++ .../tests/test_capture_reindex_preview.py | 65 + .../api_methods/views/capture_endpoints.py | 36 +- .../static/js/actions/AssetDeletionManager.js | 153 ++ .../js/actions/CaptureReindexingManager.js | 242 +++ .../__tests__/AssetDeletionManager.test.js | 51 + .../CaptureReindexingManager.test.js | 83 + .../sds_gateway/static/js/core/APIClient.js | 35 +- .../sds_gateway/static/js/core/DOMUtils.js | 1430 +++++++++-------- .../static/js/core/ModalManager.js | 38 +- .../static/js/search/KeywordChipInput.js | 32 +- .../search/__tests__/KeywordChipInput.test.js | 77 +- .../templates/users/capture_list.html | 12 + .../templates/users/components/loading.html | 12 +- .../templates/users/dataset_list.html | 6 + .../users/partials/delete_asset_modal.html | 37 + .../users/partials/reindex_capture_modal.html | 64 + gateway/sds_gateway/users/urls.py | 6 + gateway/sds_gateway/users/views/__init__.py | 2 + gateway/sds_gateway/users/views/captures.py | 74 + gateway/sds_gateway/users/views/datasets.py | 21 + 22 files changed, 1999 insertions(+), 764 deletions(-) create mode 100644 .cursor/skills/new-javascript-manager/SKILL.md create mode 100644 gateway/sds_gateway/api_methods/helpers/capture_reindex_preview.py create mode 100644 gateway/sds_gateway/api_methods/tests/test_capture_reindex_preview.py create mode 100644 gateway/sds_gateway/static/js/actions/AssetDeletionManager.js create mode 100644 gateway/sds_gateway/static/js/actions/CaptureReindexingManager.js create mode 100644 gateway/sds_gateway/static/js/actions/__tests__/AssetDeletionManager.test.js create mode 100644 gateway/sds_gateway/static/js/actions/__tests__/CaptureReindexingManager.test.js create mode 100644 gateway/sds_gateway/templates/users/partials/delete_asset_modal.html create mode 100644 gateway/sds_gateway/templates/users/partials/reindex_capture_modal.html diff --git a/.cursor/skills/new-javascript-manager/SKILL.md b/.cursor/skills/new-javascript-manager/SKILL.md new file mode 100644 index 000000000..ca5296bde --- /dev/null +++ b/.cursor/skills/new-javascript-manager/SKILL.md @@ -0,0 +1,175 @@ +--- +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): + +``` +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 000000000..cf75d4c37 --- /dev/null +++ b/gateway/sds_gateway/api_methods/helpers/capture_reindex_preview.py @@ -0,0 +1,112 @@ +"""Discover files under a capture path that would change on capture update (reindex).""" + +from __future__ import annotations + +from pathlib import Path +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 +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 000000000..010574977 --- /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 aad1708ad..94f23808e 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/static/js/actions/AssetDeletionManager.js b/gateway/sds_gateway/static/js/actions/AssetDeletionManager.js new file mode 100644 index 000000000..4cd728119 --- /dev/null +++ b/gateway/sds_gateway/static/js/actions/AssetDeletionManager.js @@ -0,0 +1,153 @@ +/** + * 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.nameEl = document.getElementById("delete-asset-name"); + 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.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") || "", + ); + }); + + if (!this.modalEl) return; + + this.modalEl.addEventListener("show.bs.modal", () => { + this.clearInlineMessage(); + if (this.confirmBtn) { + this.confirmBtn.disabled = false; + } + }); + + if (this.confirmBtn) { + this.confirmBtn.addEventListener("click", () => void this.confirmDeletion()); + } + } + + openForAsset(assetType, assetUuid, assetName) { + this.assetType = assetType; + this.assetUuid = assetUuid; + this.assetName = assetName || "this asset"; + + const typeLabel = + assetType === "dataset" + ? "dataset" + : assetType === "capture" + ? "capture" + : "asset"; + if (this.typeLabelEl) { + this.typeLabelEl.textContent = typeLabel; + } + if (this.nameEl) { + this.nameEl.textContent = this.assetName; + } + + this.openModal(this.modalId); + } + + 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() { + 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", () => { + if (!window.listRefreshManager?.loadTable) return; + const params = Object.fromEntries( + new URLSearchParams(window.location.search), + ); + void window.listRefreshManager.loadTable(params, { + showLoading: false, + }); + }); + } 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; + } + } + + 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(); + } + } +} + +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 000000000..c5e1854a2 --- /dev/null +++ b/gateway/sds_gateway/static/js/actions/CaptureReindexingManager.js @@ -0,0 +1,242 @@ +/** + * 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", + () => { + if (!window.listRefreshManager?.loadTable) return; + const params = Object.fromEntries( + new URLSearchParams(window.location.search), + ); + void window.listRefreshManager.loadTable(params, { + showLoading: false, + }); + }, + ); + } 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; + } + } + + 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(); + } + } +} + +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 000000000..5893ef862 --- /dev/null +++ b/gateway/sds_gateway/static/js/actions/__tests__/AssetDeletionManager.test.js @@ -0,0 +1,51 @@ +/** + * 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/", + ); + }); +}); 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 000000000..5ef1ada6b --- /dev/null +++ b/gateway/sds_gateway/static/js/actions/__tests__/CaptureReindexingManager.test.js @@ -0,0 +1,83 @@ +/** + * 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 077bd2235..73e8281a9 100644 --- a/gateway/sds_gateway/static/js/core/APIClient.js +++ b/gateway/sds_gateway/static/js/core/APIClient.js @@ -213,15 +213,32 @@ class APIClient { async put(url, data = {}, loadingState = null) { const formData = this._formDataFromObject(data) - return this.request( - url, - { - method: "PUT", - body: formData, - }, - loadingState, - ) - } + return this.request( + url, + { + method: "PUT", + body: formData, + }, + 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 637d4abbe..3f8619dc8 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 @@ -22,718 +23,723 @@ * - 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 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 + * @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 diff --git a/gateway/sds_gateway/static/js/core/ModalManager.js b/gateway/sds_gateway/static/js/core/ModalManager.js index 684953183..12c6039d4 100644 --- a/gateway/sds_gateway/static/js/core/ModalManager.js +++ b/gateway/sds_gateway/static/js/core/ModalManager.js @@ -67,15 +67,35 @@ class ModalManager extends BaseManager { } } - /** - * @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) 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 */ + } + } + } /** * @param {object} config diff --git a/gateway/sds_gateway/static/js/search/KeywordChipInput.js b/gateway/sds_gateway/static/js/search/KeywordChipInput.js index 3e0c3f10d..55fb2cb02 100644 --- a/gateway/sds_gateway/static/js/search/KeywordChipInput.js +++ b/gateway/sds_gateway/static/js/search/KeywordChipInput.js @@ -110,19 +110,19 @@ class KeywordChipInput { chip.remove() } - // Add chips before the input - this.chips.forEach((keyword, index) => { - 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)} + // Add chips before the input + this.chips.forEach((keyword, index) => { + const chip = document.createElement("span"); + chip.className = + "keyword-chip badge bg-secondary d-inline-flex align-items-center gap-1 me-1"; + chip.innerHTML = ` + ${keyword} - ` + aria-label="Remove ${keyword}" + data-keyword="${keyword}"> + `; // Add remove handler - use keyword instead of index to avoid index issues const removeBtn = chip.querySelector(".btn-close") @@ -141,15 +141,9 @@ 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 - } + getKeywords() { + return this.chips; + } clear() { 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 c0a01867c..a58a4caef 100644 --- a/gateway/sds_gateway/static/js/search/__tests__/KeywordChipInput.test.js +++ b/gateway/sds_gateway/static/js/search/__tests__/KeywordChipInput.test.js @@ -438,11 +438,78 @@ describe("KeywordChipInput", () => { "", ) - expect(escaped).not.toContain(""]; + + chipInput.renderChips(); + + const chip = mockChipContainer.querySelector(".keyword-chip"); + const span = chip.querySelector("span"); + expect(span.innerHTML).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 504e83258..1fe838231 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

+ ", - ) - - 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("