diff --git a/gateway/sds_gateway/static/css/file-list.css b/gateway/sds_gateway/static/css/file-list.css index cef2deba..193b27ad 100644 --- a/gateway/sds_gateway/static/css/file-list.css +++ b/gateway/sds_gateway/static/css/file-list.css @@ -574,3 +574,14 @@ body { .progress-bar-width-0 { width: 0%; } + +/* Selection column: hidden by default, shown when selection mode is active */ +#captures-table .capture-select-column { + display: none; + width: 2.5rem; + vertical-align: middle; +} + +#captures-table.selection-mode-active .capture-select-column { + display: table-cell; +} diff --git a/gateway/sds_gateway/static/js/actions/QuickAddToDatasetManager.js b/gateway/sds_gateway/static/js/actions/QuickAddToDatasetManager.js new file mode 100644 index 00000000..f4829164 --- /dev/null +++ b/gateway/sds_gateway/static/js/actions/QuickAddToDatasetManager.js @@ -0,0 +1,288 @@ +/** + * Quick Add to Dataset Manager + * Handles opening the quick-add modal, loading datasets, and adding a capture to a dataset. + */ +class QuickAddToDatasetManager { + constructor() { + this.modalEl = document.getElementById("quickAddToDatasetModal"); + this.currentCaptureUuid = null; + this.currentCaptureName = null; + /** @type {string[]|null} When set, call quick-add API once per UUID (e.g. from file list "Add" button) */ + this.currentCaptureUuids = null; + if (!this.modalEl) return; + this.quickAddUrl = this.modalEl.getAttribute("data-quick-add-url"); + this.datasetsUrl = this.modalEl.getAttribute("data-datasets-url"); + this.selectEl = document.getElementById("quick-add-dataset-select"); + this.confirmBtn = document.getElementById("quick-add-confirm-btn"); + this.messageEl = document.getElementById("quick-add-message"); + this.captureNameEl = document.getElementById("quick-add-capture-name"); + this.initializeEventListeners(); + } + + initializeEventListeners() { + // Delegate click on "Add to dataset" buttons (e.g. in table dropdown) + document.addEventListener("click", (e) => { + const btn = e.target.closest(".add-to-dataset-btn"); + if (!btn) return; + e.preventDefault(); + e.stopPropagation(); + this.currentCaptureUuid = btn.getAttribute("data-capture-uuid"); + this.currentCaptureName = + btn.getAttribute("data-capture-name") || "This capture"; + this.openModal(); + }); + + if (!this.modalEl) return; + + // When modal is shown, load datasets and apply state (single vs multi from file list) + this.modalEl.addEventListener("show.bs.modal", () => { + this.resetMessage(); + const rawIds = this.modalEl.dataset.captureUuids; + if (rawIds) { + try { + this.currentCaptureUuids = JSON.parse(rawIds); + this.currentCaptureUuid = null; + this.currentCaptureName = null; + const n = this.currentCaptureUuids.length; + if (this.captureNameEl) { + this.captureNameEl.textContent = + n === 1 ? "1 capture" : `${n} captures`; + } + delete this.modalEl.dataset.captureUuids; + } catch (_) { + this.currentCaptureUuids = null; + } + } else { + this.currentCaptureUuids = null; + if (this.captureNameEl) { + this.captureNameEl.textContent = + this.currentCaptureName || "This capture"; + } + } + this.loadDatasets(); + }); + + // When dataset select changes, enable/disable Add button + if (this.selectEl) { + this.selectEl.addEventListener("change", () => { + if (this.confirmBtn) { + this.confirmBtn.disabled = !this.selectEl.value; + } + }); + } + + // Add button click + if (this.confirmBtn) { + this.confirmBtn.addEventListener("click", () => this.handleAdd()); + } + } + + openModal() { + if (!this.modalEl) return; + const Modal = window.bootstrap?.Modal; + if (Modal) { + const modal = Modal.getOrCreateInstance(this.modalEl); + modal.show(); + } + } + + resetMessage() { + if (this.messageEl) { + this.messageEl.classList.add("d-none"); + this.messageEl.classList.remove( + "alert-success", + "alert-danger", + "alert-warning", + ); + this.messageEl.textContent = ""; + } + if (this.confirmBtn) { + this.confirmBtn.disabled = true; + } + if (this.selectEl) { + this.selectEl.innerHTML = ''; + } + } + + showMessage(text, type) { + if (!this.messageEl) return; + this.messageEl.textContent = text; + this.messageEl.classList.remove( + "d-none", + "alert-success", + "alert-danger", + "alert-warning", + ); + this.messageEl.classList.add(`alert-${type}`); + } + + async loadDatasets() { + if (!this.selectEl || !this.datasetsUrl) return; + this.selectEl.innerHTML = ''; + if (this.confirmBtn) this.confirmBtn.disabled = true; + try { + const response = await window.APIClient.get(this.datasetsUrl); + const datasets = response.datasets || []; + this.selectEl.innerHTML = ''; + for (const d of datasets) { + const opt = document.createElement("option"); + opt.value = d.uuid; + opt.textContent = d.name; + this.selectEl.appendChild(opt); + } + if (datasets.length === 0) { + this.showMessage( + "You have no datasets you can add captures to.", + "warning", + ); + } + } catch (err) { + this.selectEl.innerHTML = ''; + const reason = err?.data?.error || err?.message || "Try again."; + this.showMessage(`Failed to load datasets. ${reason}`, "danger"); + } + } + + async handleAdd() { + const datasetUuid = this.selectEl?.value; + if (!datasetUuid) return; + const isMulti = + Array.isArray(this.currentCaptureUuids) && + this.currentCaptureUuids.length > 0; + const isSingle = this.currentCaptureUuid && this.quickAddUrl; + if (!isMulti && !isSingle) return; + if (this.confirmBtn) this.confirmBtn.disabled = true; + this.resetMessage(); + if (isMulti) { + await this.handleMultiAdd(datasetUuid); + } else { + await this.handleSingleAdd(datasetUuid); + } + } + + /** + * Build a concise summary from quick-add counts (added, skipped, failed count). + * API returns detailed JSON; we show one short line. + * Failed = request threw (non-2xx HTTP or network) or response.success false or per-capture errors in 200 body. + */ + formatQuickAddSummary(added, skipped, failedCount, firstErrorMessage) { + const parts = []; + if (added > 0) parts.push(`${added} added`); + if (skipped > 0) parts.push(`${skipped} already in dataset`); + if (failedCount > 0) { + parts.push(`${failedCount} failed`); + if (firstErrorMessage != null) { + const text = + typeof firstErrorMessage === "object" + ? (firstErrorMessage.message ?? + firstErrorMessage.detail ?? + String(firstErrorMessage)) + : String(firstErrorMessage); + if (text) parts.push(`: ${text}`); + } + } + return parts.length ? `${parts.join(", ")}.` : "Done."; + } + + /** + * Call quick-add API once per selected capture UUID (loop). Backend handles + * multi-channel grouping per UUID. We aggregate counts and show one concise message. + */ + async handleMultiAdd(datasetUuid) { + if (!this.quickAddUrl) { + this.showMessage("Quick-add URL not configured.", "danger"); + if (this.confirmBtn) this.confirmBtn.disabled = false; + return; + } + let totalAdded = 0; + let totalSkipped = 0; + const errorMessages = []; + for (const captureUuid of this.currentCaptureUuids) { + try { + const response = await window.APIClient.post( + this.quickAddUrl, + { + dataset_uuid: datasetUuid, + capture_uuid: captureUuid, + }, + null, + true, + ); + if (response.success) { + totalAdded += response.added?.length ?? 0; + totalSkipped += response.skipped?.length ?? 0; + if (response.errors?.length) { + errorMessages.push(...(response.errors || [])); + } + } else { + errorMessages.push(response.error || "Request failed"); + } + } catch (err) { + // APIClient throws on non-2xx (and on network errors), so failed = exception or 4xx/5xx + errorMessages.push(err?.data?.error || err?.message || String(err)); + } + } + // failed count = requests that threw (non-200 or network) + response.success false + per-capture errors in 200 response + const errorCount = errorMessages.length; + const hasErrors = errorCount > 0; + const hasSuccess = totalAdded > 0 || totalSkipped > 0; + const msg = this.formatQuickAddSummary( + totalAdded, + totalSkipped, + errorCount, + errorMessages[0], + ); + this.showMessage(msg, hasErrors ? "warning" : "success"); + if (window.showAlert) + window.showAlert(msg, hasErrors ? "warning" : "success"); + if (hasSuccess || !hasErrors) { + setTimeout(() => { + window.bootstrap?.Modal?.getInstance(this.modalEl)?.hide(); + }, 1500); + } else if (this.confirmBtn) { + this.confirmBtn.disabled = false; + } + } + + async handleSingleAdd(datasetUuid) { + try { + const response = await window.APIClient.post( + this.quickAddUrl, + { + dataset_uuid: datasetUuid, + capture_uuid: this.currentCaptureUuid, + }, + null, + true, + ); + if (response.success) { + const added = response.added?.length ?? 0; + const skipped = response.skipped?.length ?? 0; + const errorCount = response.errors?.length ?? 0; + const firstError = response.errors?.[0]; + const msg = this.formatQuickAddSummary( + added, + skipped, + errorCount, + firstError, + ); + this.showMessage(msg, errorCount > 0 ? "warning" : "success"); + if (window.showAlert) + window.showAlert(msg, errorCount > 0 ? "warning" : "success"); + setTimeout(() => { + window.bootstrap?.Modal?.getInstance(this.modalEl)?.hide(); + }, 1500); + } else { + this.showMessage(response.error || "Request failed.", "danger"); + if (this.confirmBtn) this.confirmBtn.disabled = false; + } + } catch (err) { + const msg = + err?.data?.error || err?.message || "Failed to add capture to dataset."; + this.showMessage(msg, "danger"); + if (this.confirmBtn) this.confirmBtn.disabled = false; + } + } +} + +window.QuickAddToDatasetManager = QuickAddToDatasetManager; diff --git a/gateway/sds_gateway/static/js/components.js b/gateway/sds_gateway/static/js/components.js index ba3d8948..b2a7c695 100644 --- a/gateway/sds_gateway/static/js/components.js +++ b/gateway/sds_gateway/static/js/components.js @@ -270,7 +270,12 @@ class TableManager { const rows = this.tbody?.querySelectorAll('tr[data-clickable="true"]'); for (const row of rows || []) { row.addEventListener("click", (e) => { - if (e.target.closest("button, a")) return; // Don't trigger on buttons/links + if ( + e.target.closest( + "button, a, .capture-select-checkbox, .capture-select-column", + ) + ) + return; // Don't trigger on buttons/links/selection this.onRowClick(row); }); } diff --git a/gateway/sds_gateway/static/js/file-list.js b/gateway/sds_gateway/static/js/file-list.js index a10fa5a8..9d7bae8b 100644 --- a/gateway/sds_gateway/static/js/file-list.js +++ b/gateway/sds_gateway/static/js/file-list.js @@ -117,6 +117,53 @@ class FileListController { this.initializeAccordions(); this.initializeFrequencyHandling(); this.initializeItemsPerPageHandler(); + this.initializeAddToDatasetButton(); + } + + /** + * Selection mode: one button to enter; when on, show Cancel and Add + */ + initializeAddToDatasetButton() { + const mainBtn = document.getElementById("add-captures-to-dataset-btn"); + const table = document.getElementById("captures-table"); + const modeButtonsWrap = document.getElementById( + "add-to-dataset-mode-buttons", + ); + const cancelBtn = document.getElementById("add-to-dataset-cancel-btn"); + const addBtn = document.getElementById("add-to-dataset-add-btn"); + if (!mainBtn || !table) return; + + const enterSelectionMode = () => { + table.classList.add("selection-mode-active"); + mainBtn.classList.add("d-none"); + mainBtn.setAttribute("aria-pressed", "true"); + if (modeButtonsWrap) modeButtonsWrap.classList.remove("d-none"); + }; + + const exitSelectionMode = () => { + table.classList.remove("selection-mode-active"); + mainBtn.classList.remove("d-none"); + mainBtn.setAttribute("aria-pressed", "false"); + if (modeButtonsWrap) modeButtonsWrap.classList.add("d-none"); + }; + + mainBtn.addEventListener("click", enterSelectionMode); + + if (cancelBtn) { + cancelBtn.addEventListener("click", exitSelectionMode); + } + + if (addBtn) { + addBtn.addEventListener("click", () => { + const modal = document.getElementById("quickAddToDatasetModal"); + if (modal) { + const ids = Array.from(this.tableManager?.selectedCaptureIds ?? []); + modal.dataset.captureUuids = JSON.stringify(ids); + const bsModal = bootstrap.Modal.getOrCreateInstance(modal); + bsModal.show(); + } + }); + } } /** @@ -552,6 +599,64 @@ class FileListCapturesTableManager extends CapturesTableManager { constructor(options) { super(options); this.resultsCountElement = document.getElementById(options.resultsCountId); + this.selectedCaptureIds = new Set(); + this.setupSelectionCheckboxHandler(); + this.setupRowClickSelection(); + } + + /** + * Delegated handler for selection checkboxes: keep selectedCaptureIds in sync + */ + setupSelectionCheckboxHandler() { + document.addEventListener("change", (e) => { + if (!e.target.matches(".capture-select-checkbox")) return; + const uuid = e.target.getAttribute("data-capture-uuid"); + if (!uuid) return; + if (e.target.checked) { + this.selectedCaptureIds.add(uuid); + } else { + this.selectedCaptureIds.delete(uuid); + } + }); + } + + /** + * When selection mode is active, clicking a row toggles its selection (instead of opening the modal). + * Uses capture phase so we run before the row's click handler. + */ + setupRowClickSelection() { + const table = document.getElementById(this.tableId); + if (!table) return; + + table.addEventListener( + "click", + (e) => { + if (!table.classList.contains("selection-mode-active")) return; + if ( + e.target.closest( + "button, a, [data-bs-toggle='dropdown'], .capture-select-checkbox", + ) + ) + return; + const row = e.target.closest("tr"); + if (!row) return; + const checkbox = row.querySelector(".capture-select-checkbox"); + if (!checkbox) return; + const uuid = checkbox.getAttribute("data-capture-uuid"); + if (!uuid) return; + + if (this.selectedCaptureIds.has(uuid)) { + this.selectedCaptureIds.delete(uuid); + checkbox.checked = false; + } else { + this.selectedCaptureIds.add(uuid); + checkbox.checked = true; + } + e.preventDefault(); + e.stopPropagation(); + }, + true, + ); } /** @@ -608,7 +713,7 @@ class FileListCapturesTableManager extends CapturesTableManager { if (!hasResults || captures.length === 0) { tbody.innerHTML = `
| Select | Name | {% if captures %} {% for cap in captures %} - | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| + {% if cap.is_owner %} + + {% else %} + + {% endif %} + | - | + | No captures found matching your search criteria. | |||||||||