From 40217717f704aa5e68de15bac33276d0769598fe Mon Sep 17 00:00:00 2001 From: klpoland Date: Fri, 29 May 2026 09:53:28 -0400 Subject: [PATCH 01/11] use spectrumx theme css for dataset editor file browser --- gateway/sds_gateway/static/css/components.css | 15 +- .../static/css/spectrumx_theme.css | 5 + .../static/js/core/PageLifecycleManager.js | 30 +- .../js/dataset/DatasetCreationHandler.js | 1636 ++++++----- .../js/dataset/DatasetEditingHandler.js | 2578 ++++++++--------- .../static/js/search/AssetSearchHandler.js | 1850 ++++++------ .../__tests__/AssetSearchHandler.test.js | 864 +++--- .../users/partials/file_browser.html | 36 +- 8 files changed, 3434 insertions(+), 3580 deletions(-) diff --git a/gateway/sds_gateway/static/css/components.css b/gateway/sds_gateway/static/css/components.css index 0a9e57e52..67fcb7836 100644 --- a/gateway/sds_gateway/static/css/components.css +++ b/gateway/sds_gateway/static/css/components.css @@ -1127,19 +1127,8 @@ body { border-radius: 0.375rem; } -#file-tree-table { - margin-bottom: 0; -} - -#file-tree-table thead.sticky-top { - position: sticky; - top: 0; - z-index: 2; - background-color: var(--bs-white); -} - -#file-tree-table tbody tr:hover { - cursor: pointer; +#file-tree-root { + margin-bottom: 0; } .action-buttons { diff --git a/gateway/sds_gateway/static/css/spectrumx_theme.css b/gateway/sds_gateway/static/css/spectrumx_theme.css index 46111f26a..32485f196 100644 --- a/gateway/sds_gateway/static/css/spectrumx_theme.css +++ b/gateway/sds_gateway/static/css/spectrumx_theme.css @@ -267,6 +267,11 @@ textarea:focus-visible { max-width: 600px; } +.file-browser-modal { + max-width: none; + width: 100%; +} + .file-browser-header { display: flex; justify-content: space-between; diff --git a/gateway/sds_gateway/static/js/core/PageLifecycleManager.js b/gateway/sds_gateway/static/js/core/PageLifecycleManager.js index eed28698c..2b8a42a68 100644 --- a/gateway/sds_gateway/static/js/core/PageLifecycleManager.js +++ b/gateway/sds_gateway/static/js/core/PageLifecycleManager.js @@ -235,21 +235,21 @@ class PageLifecycleManager { this.managers.push(capturesSearchHandler) } - // Initialize files search handler - if (window.SearchHandler) { - const filesSearchHandler = new window.SearchHandler({ - searchFormId: "files-search-form", - searchButtonId: "search-files", - clearButtonId: "clear-files-search", - tableBodyId: "file-tree-table", - paginationContainerId: "files-pagination", - type: "files", - formHandler: this.datasetModeManager?.getHandler(), - isEditMode: this.datasetModeManager?.isInEditMode() || false, - }) - this.managers.push(filesSearchHandler) - } - } + // Initialize files search handler + if (window.SearchHandler) { + const filesSearchHandler = new window.SearchHandler({ + searchFormId: "files-search-form", + searchButtonId: "search-files", + clearButtonId: "clear-files-search", + tableBodyId: "file-tree-root", + paginationContainerId: "files-pagination", + type: "files", + formHandler: this.datasetModeManager?.getHandler(), + isEditMode: this.datasetModeManager?.isInEditMode() || false, + }); + this.managers.push(filesSearchHandler); + } + } /** * Initialize sort functionality diff --git a/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js b/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js index 8237a9de8..de15c1e82 100644 --- a/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js +++ b/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js @@ -3,831 +3,817 @@ * Handles dataset creation workflow and form management */ class DatasetCreationHandler extends BaseManager { - /** - * Initialize dataset creation handler - * @param {Object} config - Configuration object - */ - constructor(config) { - super() - this.currentUserId = config.currentUserId - this.form = document.getElementById(config.formId) - this.steps = config.steps || [] - this.currentStep = 0 - this.onStepChange = config.onStepChange - - // Navigation elements - this.prevBtn = document.getElementById("prevStep") - this.nextBtn = document.getElementById("nextStep") - this.submitBtn = document.getElementById("submitForm") - this.stepTabs = document.querySelectorAll("#stepTabs .btn") - - // Form fields - this.nameField = document.getElementById("id_name") - this.authorsField = document.getElementById("id_authors") - this.statusField = document.getElementById("id_status") - this.descriptionField = document.getElementById("id_description") - this.visibilityField = document.querySelector( - 'input[name="is_public"]:checked', - ) - - // Hidden fields - this.selectedCapturesField = - document.getElementById("selected_captures") - this.selectedFilesField = document.getElementById("selected_files") - - // Selections - this.selectedCaptures = new Set() // Set of capture IDs - this.selectedFiles = new Set() // Set of file objects for the main card - this.selectedCaptureDetails = new Map() // Map of capture ID -> capture details - - // File browser modal state (intermediate selections) - this.modalSelectedFiles = new Set() // Set of file objects for modal intermediate state - - // Search handlers - this.capturesSearchHandler = null - this.filesSearchHandler = null - - this.initializeEventListeners() - this.initializeErrorContainer() - this.initializeAuthorsManagement() - this.initializePlaceholders() - this.validateCurrentStep() - this.updateNavigation() - } - - /** - * Initialize event listeners - */ - initializeEventListeners() { - // Initialize search handlers if they exist - if (window.AssetSearchHandler) { - this.capturesSearchHandler = new window.AssetSearchHandler({ - searchFormId: "captures-search-form", - searchButtonId: "search-captures", - clearButtonId: "clear-captures-search", - tableBodyId: "captures-table-body", - paginationContainerId: "captures-pagination", - type: "captures", - formHandler: this, - isEditMode: false, - apiEndpoint: window.location.pathname, - }) - - this.filesSearchHandler = new window.AssetSearchHandler({ - searchFormId: "files-search-form", - searchButtonId: "search-files", - clearButtonId: "clear-files-search", - tableBodyId: "file-tree-table", - paginationContainerId: "files-pagination", - confirmFileSelectionId: "confirm-file-selection", - type: "files", - formHandler: this, - isEditMode: false, - apiEndpoint: window.location.pathname, - }) - - // Initialize captures search to show initial state - if ( - this.capturesSearchHandler && - typeof this.capturesSearchHandler.initializeCapturesSearch === - "function" - ) { - this.capturesSearchHandler.initializeCapturesSearch() - } - } - - // Prevent form submission on enter key - if (this.form) { - this.form.addEventListener("keypress", (e) => { - if (e.key === "Enter") { - e.preventDefault() - } - }) - } - - // Navigation buttons - if (this.prevBtn) { - this.prevBtn.addEventListener("click", (e) => { - e.stopPropagation() - this.navigateStep(-1) - }) - } - - if (this.nextBtn) { - this.nextBtn.addEventListener("click", (e) => { - e.stopPropagation() - this.navigateStep(1) - }) - } - - // Step tab handlers - for (const [index, tab] of this.stepTabs.entries()) { - tab.addEventListener("click", () => { - if (index <= this.currentStep) { - this.currentStep = index - this.updateNavigation() - if (this.onStepChange) { - this.onStepChange(this.currentStep) - } - } - }) - } - - // Form field validation - if (this.nameField) { - this.nameField.addEventListener("input", () => - this.validateCurrentStep(), - ) - } - if (this.authorsField) { - this.authorsField.addEventListener("input", () => - this.validateCurrentStep(), - ) - } - if (this.statusField) { - this.statusField.addEventListener("change", () => - this.validateCurrentStep(), - ) - } - - // Capture selection handler (direct table selection) - document.addEventListener("change", (e) => { - if (e.target.matches('input[name="captures"]')) { - this.handleCaptureSelection(e.target) - } - }) - - // File browser modal handlers - this.initializeFileBrowserModal() - } - - /** - * Initialize error container - */ - initializeErrorContainer() { - const errorContainer = document.getElementById("formErrors") - if (!errorContainer) return - - window.DOMUtils.hide(errorContainer) - } - - /** - * Initialize placeholder text for empty tables - */ - initializePlaceholders() { - // Initialize selected files table with placeholder - const selectedFilesTable = document.getElementById( - "selected-files-table", - ) - const selectedFilesBody = selectedFilesTable?.querySelector("tbody") - if (selectedFilesBody && selectedFilesBody.innerHTML.trim() === "") { - selectedFilesBody.innerHTML = - 'No files selected' - } - - // Initialize captures selection table with placeholder - const capturesSelectionTable = document.getElementById( - "captures-table-body", - ) - if ( - capturesSelectionTable && - capturesSelectionTable.innerHTML.trim() === "" - ) { - capturesSelectionTable.innerHTML = - 'No captures found' - } - - // Initialize captures table on review step with placeholder (will be updated later) - const capturesTable = document.querySelector( - "#step5 .captures-table tbody", - ) - if (capturesTable && capturesTable.innerHTML.trim() === "") { - capturesTable.innerHTML = - 'No captures selected' - } - - // Initialize files table on review step with placeholder (will be updated later) - const filesTable = document.querySelector("#step5 .files-table tbody") - if (filesTable && filesTable.innerHTML.trim() === "") { - filesTable.innerHTML = - 'No files selected' - } - } - - /** - * Initialize file browser modal handlers - */ - initializeFileBrowserModal() { - // Modal file selection handlers - document.addEventListener("change", (e) => { - if (e.target.matches('#file-tree-table input[name="files"]')) { - this.handleModalFileSelection(e.target) - } - }) - - // Select all files checkbox - const selectAllCheckbox = document.getElementById( - "select-all-files-checkbox", - ) - if (selectAllCheckbox) { - selectAllCheckbox.addEventListener("change", (e) => { - this.handleSelectAllFiles(e.target.checked) - }) - } - - // Confirm file selection button - const confirmButton = document.getElementById("confirm-file-selection") - if (confirmButton) { - confirmButton.addEventListener("click", () => { - this.confirmFileSelection() - }) - } - - window.AuthorsManager?.bindFileTreeModalHandlers(this) - - // Remove all files button - const removeAllButton = document.getElementById( - "remove-all-selected-files-button", - ) - if (!removeAllButton) return - - removeAllButton.addEventListener("click", () => { - this.removeAllSelectedFiles() - }) - } - - /** - * Handle capture selection from direct table - * @param {Element} checkbox - Checkbox element - */ - handleCaptureSelection(checkbox) { - const captureId = checkbox.value - const row = checkbox.closest("tr") - - if (checkbox.checked) { - // Add to selected captures - this.selectedCaptures.add(captureId) - - // Highlight the row - if (row) { - row.classList.add("table-warning") - } - - // Store capture details if available from search handler - if (this.capturesSearchHandler?.selectedCaptureDetails) { - const captureDetails = - this.capturesSearchHandler.selectedCaptureDetails.get( - captureId, - ) - if (captureDetails) { - this.selectedCaptureDetails.set(captureId, captureDetails) - } - } - } else { - // Remove from selected captures - this.selectedCaptures.delete(captureId) - this.selectedCaptureDetails.delete(captureId) - - // Remove highlight from row - if (row) { - row.classList.remove("table-warning") - } - } - - this.updateHiddenFields() - void this.updateSelectedCapturesPanel() - } - - /** - * Handle modal file selection (intermediate state) - * @param {Element} checkbox - Checkbox element - */ - handleModalFileSelection(checkbox) { - const fileId = checkbox.value - const row = checkbox.closest("tr") - - // Get file data from the row - const fileData = this.getFileDataFromRow(row) - - if (checkbox.checked) { - // Add to modal intermediate selection - this.modalSelectedFiles.add(fileData) - } else { - // Remove from modal intermediate selection - this.modalSelectedFiles.delete(fileData) - } - - // Update select all checkbox state - this.updateSelectAllCheckbox() - } - - /** - * Get file data from table row - * @param {Element} row - Table row element - * @returns {Object} File data object - */ - getFileDataFromRow(row) { - const cells = row.querySelectorAll("td") - return { - id: row.querySelector('input[name="files"]').value, - name: cells[1]?.textContent?.trim() || "", - media_type: cells[2]?.textContent?.trim() || "", - relative_path: cells[3]?.textContent?.trim() || "", - size: cells[4]?.textContent?.trim() || "", - created_at: cells[5]?.textContent?.trim() || "", - } - } - - /** - * Handle select all files checkbox - * @param {boolean} checked - Whether select all is checked - */ - handleSelectAllFiles(checked) { - const checkboxes = document.querySelectorAll( - '#file-tree-table input[name="files"]', - ) - for (const checkbox of checkboxes) { - checkbox.checked = checked - this.handleModalFileSelection(checkbox) - } - } - - /** - * Update select all checkbox state - */ - updateSelectAllCheckbox() { - const selectAllCheckbox = document.getElementById( - "select-all-files-checkbox", - ) - const allCheckboxes = document.querySelectorAll( - '#file-tree-table input[name="files"]', - ) - - if (selectAllCheckbox && allCheckboxes.length > 0) { - const checkedCount = Array.from(allCheckboxes).filter( - (cb) => cb.checked, - ).length - selectAllCheckbox.checked = checkedCount === allCheckboxes.length - selectAllCheckbox.indeterminate = - checkedCount > 0 && checkedCount < allCheckboxes.length - } - } - - /** - * Confirm file selection (move from modal to main selection) - */ - confirmFileSelection() { - // Add modal selections to main selection - for (const file of this.modalSelectedFiles) { - this.selectedFiles.add(file) - } - - // Clear modal selections - this.modalSelectedFiles.clear() - - // Update UI - this.updateSelectedFilesDisplay() - this.updateHiddenFields() - - // Close modal - const modal = bootstrap.Modal.getInstance( - document.getElementById("fileTreeModal"), - ) - if (modal) { - modal.hide() - } - } - - /** - * Handle file modal show - */ - onFileModalShow() { - // Trigger initial file tree loading if filesSearchHandler exists and tree hasn't been loaded - if (this.filesSearchHandler && !this.filesSearchHandler.currentTree) { - this.filesSearchHandler.handleSearch() - } - } - - /** - * Handle file modal hide - */ - onFileModalHide() { - // Clear any intermediate state if needed - this.modalSelectedFiles.clear() - } - - /** - * Remove all selected files - */ - removeAllSelectedFiles() { - // Clear main selection - this.selectedFiles.clear() - - // Clear modal intermediate selection - this.modalSelectedFiles.clear() - - // Update UI - this.updateSelectedFilesDisplay() - this.updateHiddenFields() - - // Uncheck all checkboxes in modal if it's open - const checkboxes = document.querySelectorAll( - '#file-tree-table input[name="files"]', - ) - for (const checkbox of checkboxes) { - checkbox.checked = false - } - - // Update select all checkbox - this.updateSelectAllCheckbox() - } - - /** - * Update selected files display - */ - updateSelectedFilesDisplay() { - const displayInput = document.getElementById("selected-files-display") - if (!displayInput) return - - const count = this.selectedFiles.size - displayInput.value = `${count} file(s) selected` - - // Update selected files table - this.updateSelectedFilesTable() - } - - /** - * Navigate between steps - * @param {number} direction - Direction to navigate (-1 or 1) - */ - async navigateStep(direction) { - if (direction < 0 || this.validateCurrentStep()) { - const nextStep = this.currentStep + direction - - // Update review step if moving to it (step 5 = index 4) - if (nextStep === 4) { - this.updateReviewStep() - } - - this.currentStep = nextStep - this.updateNavigation() - this.updateDatasetNameDisplay() - - if (this.onStepChange) { - this.onStepChange(this.currentStep) - } - } - } - - /** - * Update review step content - */ - updateReviewStep() { - // Update dataset name - const nameDisplay = document.querySelector("#step5 .dataset-name") - if (nameDisplay) { - nameDisplay.textContent = this.nameField ? this.nameField.value : "" - } - - // Update authors display - if (window.updateReviewAuthorsDisplay) { - window.updateReviewAuthorsDisplay() - } else { - this.updateAuthorsDisplayFallback() - } - - // Status and visibility are now handled in publishing info panel by DatasetModeManager - - // Update description display - const descriptionDisplay = document.querySelector( - "#step5 .dataset-description", - ) - if (descriptionDisplay) { - descriptionDisplay.textContent = this.descriptionField - ? this.descriptionField.value - : "No description provided." - } - - // Update selected items table - this.updateSelectedItemsTable() - } - - /** - * Update authors display fallback - */ - updateAuthorsDisplayFallback() { - const authorsField = document.getElementById("id_authors") - const authorsDisplay = document.querySelector("#step5 .dataset-authors") - - if (authorsField?.value && authorsDisplay) { - try { - const authors = JSON.parse(authorsField.value) - const authorNames = authors.map((author) => { - if (typeof author === "string") { - return author - } - if (author?.name) { - return author.name - } - return "Unnamed Author" - }) - authorsDisplay.textContent = authorNames.join(", ") - } catch (e) { - authorsDisplay.textContent = authorsField.value - } - } else if (authorsDisplay) { - authorsDisplay.textContent = "No authors specified" - } - } - - /** - * Update dataset name display - */ - updateDatasetNameDisplay() { - const nameDisplays = document.getElementsByClassName( - "dataset-name-display", - ) - if (this.nameField && nameDisplays.length > 0) { - for (const nameDisplay of Array.from(nameDisplays)) { - nameDisplay.textContent = - this.nameField.value || "Untitled Dataset" - } - } - } - - /** - * Update navigation state - */ - updateNavigation() { - // Update step tabs - for (const [index, tab] of this.stepTabs.entries()) { - tab.classList.remove( - "btn-outline-primary", - "btn-primary", - "active-tab", - "inactive-tab", - ) - - if (index === this.currentStep) { - tab.classList.add("btn-primary", "active-tab") - } else if (index > this.currentStep) { - tab.classList.add("btn-outline-primary", "inactive-tab") - } else { - tab.classList.add("btn-primary", "inactive-tab") - } - } - - // Update content panes - for (const [index, pane] of document - .querySelectorAll(".tab-pane") - .entries()) { - pane.classList.remove("show", "active") - if (index === this.currentStep) { - pane.classList.add("show", "active") - } - } - - // Update navigation buttons - if (this.prevBtn) { - if (this.currentStep > 0) { - window.DOMUtils.show(this.prevBtn) - } else { - window.DOMUtils.hide(this.prevBtn) - } - } - - const isValid = this.validateCurrentStep() - - if (this.nextBtn) { - const isLastStep = this.currentStep === this.steps.length - 1 - if (isLastStep) { - window.DOMUtils.hide(this.nextBtn) - } else { - window.DOMUtils.show(this.nextBtn) - } - this.nextBtn.disabled = !isValid - } - - if (this.submitBtn) { - // Only show submit button on final step (step 5, index 4) - if (this.currentStep === 4) { - window.DOMUtils.show(this.submitBtn, "display-inline-block") - this.submitBtn.disabled = !isValid - } else { - window.DOMUtils.hide(this.submitBtn, "display-inline-block") - } - } - } - - /** - * Validate current step - * @returns {boolean} Whether current step is valid - */ - validateCurrentStep() { - let isValid = true - - switch (this.currentStep) { - case 0: - isValid = this.validateDatasetInfo() - break - case 1: - isValid = this.validateCapturesSelection() - break - case 2: - isValid = this.validateFilesSelection() - break - default: - isValid = true - } - - // Update button states - if (this.nextBtn) { - this.nextBtn.disabled = !isValid - } - if (this.submitBtn && this.currentStep === 4) { - this.submitBtn.disabled = !isValid - } - - return isValid - } - - /** - * Validate dataset info step - * @returns {boolean} Whether dataset info is valid - */ - validateDatasetInfo() { - const nameValue = this.nameField?.value.trim() || "" - const authorsValue = this.authorsField?.value.trim() || "" - - // Validate authors JSON and first author name - if (authorsValue) { - try { - const authors = JSON.parse(authorsValue) - if (!Array.isArray(authors) || authors.length === 0) { - return false - } - - // Check that the first author has a name - const firstAuthor = authors[0] - if ( - !firstAuthor || - !firstAuthor.name || - firstAuthor.name.trim() === "" - ) { - return false - } - } catch (e) { - return false - } - } else { - return false // Authors field is required - } - - return nameValue !== "" - } - - /** - * Validate captures selection step - * @returns {boolean} Whether captures selection is valid - */ - validateCapturesSelection() { - return true // Captures selection is optional - } - - /** - * Validate files selection step - * @returns {boolean} Whether files selection is valid - */ - validateFilesSelection() { - return true // Files selection is optional - } - - /** - * Handle form submission - * @param {Event} e - Submit event - */ - async handleSubmit(e) { - e.preventDefault() - - if (!this.validateCurrentStep()) { - return - } - - // Set loading state - this.setSubmitButtonLoading(true) - - // Update hidden fields - this.updateHiddenFields() - - // Clear existing errors - this.clearErrors() - - const formData = new FormData(this.form) - - try { - const response = await window.APIClient.request(this.form.action, { - method: "POST", - body: formData, - }) - - if (response.success) { - window.location.href = response.redirect_url - } else if (response.errors) { - throw new APIError("Validation failed", 400, response.errors) - } - } catch (error) { - console.error("Error submitting form:", error) - this.handleSubmissionError(error) - } finally { - this.setSubmitButtonLoading(false) - } - } - - /** - * Handle submission error - * @param {Error} error - Error object - */ - async handleSubmissionError(error) { - const errorContainer = document.getElementById("formErrors") - if (!errorContainer) return - - try { - // Normalize error context - const context = {} - - if (error instanceof APIError && error.data.errors) { - // Normalize field errors into list format for template - context.error_list = [] - for (const [field, messages] of Object.entries( - error.data.errors, - )) { - const messageList = Array.isArray(messages) - ? messages - : [messages] - for (const msg of messageList) { - context.error_list.push([field, msg]) - } - } - context.show_field_names = true - } else { - context.message = - "An unexpected error occurred. Please try again." - } - - const messageText = context.message ?? "" - const templateContext = { - alert_type: "danger", - icon: "exclamation-triangle-fill", - } - if (context.error_list) { - templateContext.error_list = context.error_list - templateContext.show_field_names = context.show_field_names - } - - const success = await window.DOMUtils.showMessage(messageText, { - variant: "danger", - placement: "replace", - target: errorContainer, - presentation: "alert", - templateContext, - }) - if (success) { - window.DOMUtils.show(errorContainer) - errorContainer.scrollIntoView({ - behavior: "smooth", - block: "nearest", - }) - } - } catch (err) { - console.error("Error rendering error message:", err) - // Fallback to simple text - errorContainer.textContent = "An error occurred. Please try again." - window.DOMUtils.show(errorContainer) - } - } - - /** - * Clear form errors - */ - clearErrors() { - const errorContainer = document.getElementById("formErrors") - if (errorContainer) { - errorContainer.innerHTML = "" - window.DOMUtils.hide(errorContainer) - } - } - - /** - * Set submit button loading state - * @param {boolean} isLoading - Whether button is loading - */ - setSubmitButtonLoading(isLoading) { - if (!this.submitBtn) return - - if (isLoading) { - this.submitBtn.dataset.originalText = this.submitBtn.textContent - this.submitBtn.disabled = true - this.submitBtn.innerHTML = ` + /** + * Initialize dataset creation handler + * @param {Object} config - Configuration object + */ + constructor(config) { + super(); + this.currentUserId = config.currentUserId; + this.form = document.getElementById(config.formId); + this.steps = config.steps || []; + this.currentStep = 0; + this.onStepChange = config.onStepChange; + + // Navigation elements + this.prevBtn = document.getElementById("prevStep"); + this.nextBtn = document.getElementById("nextStep"); + this.submitBtn = document.getElementById("submitForm"); + this.stepTabs = document.querySelectorAll("#stepTabs .btn"); + + // Form fields + this.nameField = document.getElementById("id_name"); + this.authorsField = document.getElementById("id_authors"); + this.statusField = document.getElementById("id_status"); + this.descriptionField = document.getElementById("id_description"); + this.visibilityField = document.querySelector( + 'input[name="is_public"]:checked', + ); + + // Hidden fields + this.selectedCapturesField = document.getElementById("selected_captures"); + this.selectedFilesField = document.getElementById("selected_files"); + + // Selections + this.selectedCaptures = new Set(); // Set of capture IDs + this.selectedFiles = new Set(); // Set of file objects for the main card + this.selectedCaptureDetails = new Map(); // Map of capture ID -> capture details + + // File browser modal state (intermediate selections) + this.modalSelectedFiles = new Set(); // Set of file objects for modal intermediate state + + // Search handlers + this.capturesSearchHandler = null; + this.filesSearchHandler = null; + + this.initializeEventListeners(); + this.initializeErrorContainer(); + this.initializeAuthorsManagement(); + this.initializePlaceholders(); + this.validateCurrentStep(); + this.updateNavigation(); + } + + /** + * Initialize event listeners + */ + initializeEventListeners() { + // Initialize search handlers if they exist + if (window.AssetSearchHandler) { + this.capturesSearchHandler = new window.AssetSearchHandler({ + searchFormId: "captures-search-form", + searchButtonId: "search-captures", + clearButtonId: "clear-captures-search", + tableBodyId: "captures-table-body", + paginationContainerId: "captures-pagination", + type: "captures", + formHandler: this, + isEditMode: false, + apiEndpoint: window.location.pathname, + }); + + this.filesSearchHandler = new window.AssetSearchHandler({ + searchFormId: "files-search-form", + searchButtonId: "search-files", + clearButtonId: "clear-files-search", + tableBodyId: "file-tree-root", + paginationContainerId: "files-pagination", + confirmFileSelectionId: "confirm-file-selection", + type: "files", + formHandler: this, + isEditMode: false, + apiEndpoint: window.location.pathname, + }); + + // Initialize captures search to show initial state + if ( + this.capturesSearchHandler && + typeof this.capturesSearchHandler.initializeCapturesSearch === + "function" + ) { + this.capturesSearchHandler.initializeCapturesSearch(); + } + } + + // Prevent form submission on enter key + if (this.form) { + this.form.addEventListener("keypress", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + } + }); + } + + // Navigation buttons + if (this.prevBtn) { + this.prevBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.navigateStep(-1); + }); + } + + if (this.nextBtn) { + this.nextBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.navigateStep(1); + }); + } + + // Step tab handlers + for (const [index, tab] of this.stepTabs.entries()) { + tab.addEventListener("click", () => { + if (index <= this.currentStep) { + this.currentStep = index; + this.updateNavigation(); + if (this.onStepChange) { + this.onStepChange(this.currentStep); + } + } + }); + } + + // Form field validation + if (this.nameField) { + this.nameField.addEventListener("input", () => + this.validateCurrentStep(), + ); + } + if (this.authorsField) { + this.authorsField.addEventListener("input", () => + this.validateCurrentStep(), + ); + } + if (this.statusField) { + this.statusField.addEventListener("change", () => + this.validateCurrentStep(), + ); + } + + // Capture selection handler (direct table selection) + document.addEventListener("change", (e) => { + if (e.target.matches('input[name="captures"]')) { + this.handleCaptureSelection(e.target); + } + }); + + // File browser modal handlers + this.initializeFileBrowserModal(); + } + + /** + * Initialize error container + */ + initializeErrorContainer() { + const errorContainer = document.getElementById("formErrors"); + if (!errorContainer) return; + + window.DOMUtils.hide(errorContainer); + } + + /** + * Initialize placeholder text for empty tables + */ + initializePlaceholders() { + // Initialize selected files table with placeholder + const selectedFilesTable = document.getElementById("selected-files-table"); + const selectedFilesBody = selectedFilesTable?.querySelector("tbody"); + if (selectedFilesBody && selectedFilesBody.innerHTML.trim() === "") { + selectedFilesBody.innerHTML = + 'No files selected'; + } + + // Initialize captures selection table with placeholder + const capturesSelectionTable = document.getElementById( + "captures-table-body", + ); + if ( + capturesSelectionTable && + capturesSelectionTable.innerHTML.trim() === "" + ) { + capturesSelectionTable.innerHTML = + 'No captures found'; + } + + // Initialize captures table on review step with placeholder (will be updated later) + const capturesTable = document.querySelector( + "#step5 .captures-table tbody", + ); + if (capturesTable && capturesTable.innerHTML.trim() === "") { + capturesTable.innerHTML = + 'No captures selected'; + } + + // Initialize files table on review step with placeholder (will be updated later) + const filesTable = document.querySelector("#step5 .files-table tbody"); + if (filesTable && filesTable.innerHTML.trim() === "") { + filesTable.innerHTML = + 'No files selected'; + } + } + + /** + * Initialize file browser modal handlers + */ + initializeFileBrowserModal() { + // Modal file selection handlers + document.addEventListener("change", (e) => { + if (e.target.matches('#file-tree-root input[name="files"]')) { + this.handleModalFileSelection(e.target); + } + }); + + // Select all files checkbox + const selectAllCheckbox = document.getElementById( + "select-all-files-checkbox", + ); + if (selectAllCheckbox) { + selectAllCheckbox.addEventListener("change", (e) => { + this.handleSelectAllFiles(e.target.checked); + }); + } + + // Confirm file selection button + const confirmButton = document.getElementById("confirm-file-selection"); + if (confirmButton) { + confirmButton.addEventListener("click", () => { + this.confirmFileSelection(); + }); + } + + window.AuthorsManager?.bindFileTreeModalHandlers(this); + + // Remove all files button + const removeAllButton = document.getElementById( + "remove-all-selected-files-button", + ); + if (!removeAllButton) return; + + removeAllButton.addEventListener("click", () => { + this.removeAllSelectedFiles(); + }); + } + + /** + * Handle capture selection from direct table + * @param {Element} checkbox - Checkbox element + */ + handleCaptureSelection(checkbox) { + const captureId = checkbox.value; + const row = checkbox.closest("tr"); + + if (checkbox.checked) { + // Add to selected captures + this.selectedCaptures.add(captureId); + + // Highlight the row + if (row) { + row.classList.add("table-warning"); + } + + // Store capture details if available from search handler + if (this.capturesSearchHandler?.selectedCaptureDetails) { + const captureDetails = + this.capturesSearchHandler.selectedCaptureDetails.get(captureId); + if (captureDetails) { + this.selectedCaptureDetails.set(captureId, captureDetails); + } + } + } else { + // Remove from selected captures + this.selectedCaptures.delete(captureId); + this.selectedCaptureDetails.delete(captureId); + + // Remove highlight from row + if (row) { + row.classList.remove("table-warning"); + } + } + + this.updateHiddenFields(); + void this.updateSelectedCapturesPanel(); + } + + /** + * Handle modal file selection (intermediate state) + * @param {Element} checkbox - Checkbox element + */ + handleModalFileSelection(checkbox) { + const fileId = checkbox.value; + const row = checkbox.closest("tr"); + + // Get file data from the row + const fileData = this.getFileDataFromRow(row); + + if (checkbox.checked) { + // Add to modal intermediate selection + this.modalSelectedFiles.add(fileData); + } else { + // Remove from modal intermediate selection + this.modalSelectedFiles.delete(fileData); + } + + // Update select all checkbox state + this.updateSelectAllCheckbox(); + } + + /** + * Get file data from table row + * @param {Element} row - Table row element + * @returns {Object} File data object + */ + getFileDataFromRow(row) { + const cells = row.querySelectorAll("td"); + return { + id: row.querySelector('input[name="files"]').value, + name: cells[1]?.textContent?.trim() || "", + media_type: cells[2]?.textContent?.trim() || "", + relative_path: cells[3]?.textContent?.trim() || "", + size: cells[4]?.textContent?.trim() || "", + created_at: cells[5]?.textContent?.trim() || "", + }; + } + + /** + * Handle select all files checkbox + * @param {boolean} checked - Whether select all is checked + */ + handleSelectAllFiles(checked) { + const checkboxes = document.querySelectorAll( + '#file-tree-root input[name="files"]', + ); + for (const checkbox of checkboxes) { + checkbox.checked = checked; + this.handleModalFileSelection(checkbox); + } + } + + /** + * Update select all checkbox state + */ + updateSelectAllCheckbox() { + const selectAllCheckbox = document.getElementById( + "select-all-files-checkbox", + ); + const allCheckboxes = document.querySelectorAll( + '#file-tree-root input[name="files"]', + ); + + if (selectAllCheckbox && allCheckboxes.length > 0) { + const checkedCount = Array.from(allCheckboxes).filter( + (cb) => cb.checked, + ).length; + selectAllCheckbox.checked = checkedCount === allCheckboxes.length; + selectAllCheckbox.indeterminate = + checkedCount > 0 && checkedCount < allCheckboxes.length; + } + } + + /** + * Confirm file selection (move from modal to main selection) + */ + confirmFileSelection() { + // Add modal selections to main selection + for (const file of this.modalSelectedFiles) { + this.selectedFiles.add(file); + } + + // Clear modal selections + this.modalSelectedFiles.clear(); + + // Update UI + this.updateSelectedFilesDisplay(); + this.updateHiddenFields(); + + // Close modal + const modal = bootstrap.Modal.getInstance( + document.getElementById("fileTreeModal"), + ); + if (modal) { + modal.hide(); + } + } + + /** + * Handle file modal show + */ + onFileModalShow() { + // Trigger initial file tree loading if filesSearchHandler exists and tree hasn't been loaded + if (this.filesSearchHandler && !this.filesSearchHandler.currentTree) { + this.filesSearchHandler.handleSearch(); + } + } + + /** + * Handle file modal hide + */ + onFileModalHide() { + // Clear any intermediate state if needed + this.modalSelectedFiles.clear(); + } + + /** + * Remove all selected files + */ + removeAllSelectedFiles() { + // Clear main selection + this.selectedFiles.clear(); + + // Clear modal intermediate selection + this.modalSelectedFiles.clear(); + + // Update UI + this.updateSelectedFilesDisplay(); + this.updateHiddenFields(); + + // Uncheck all checkboxes in modal if it's open + const checkboxes = document.querySelectorAll( + '#file-tree-root input[name="files"]', + ); + for (const checkbox of checkboxes) { + checkbox.checked = false; + } + + // Update select all checkbox + this.updateSelectAllCheckbox(); + } + + /** + * Update selected files display + */ + updateSelectedFilesDisplay() { + const displayInput = document.getElementById("selected-files-display"); + if (!displayInput) return; + + const count = this.selectedFiles.size; + displayInput.value = `${count} file(s) selected`; + + // Update selected files table + this.updateSelectedFilesTable(); + } + + /** + * Navigate between steps + * @param {number} direction - Direction to navigate (-1 or 1) + */ + async navigateStep(direction) { + if (direction < 0 || this.validateCurrentStep()) { + const nextStep = this.currentStep + direction; + + // Update review step if moving to it (step 5 = index 4) + if (nextStep === 4) { + this.updateReviewStep(); + } + + this.currentStep = nextStep; + this.updateNavigation(); + this.updateDatasetNameDisplay(); + + if (this.onStepChange) { + this.onStepChange(this.currentStep); + } + } + } + + /** + * Update review step content + */ + updateReviewStep() { + // Update dataset name + const nameDisplay = document.querySelector("#step5 .dataset-name"); + if (nameDisplay) { + nameDisplay.textContent = this.nameField ? this.nameField.value : ""; + } + + // Update authors display + if (window.updateReviewAuthorsDisplay) { + window.updateReviewAuthorsDisplay(); + } else { + this.updateAuthorsDisplayFallback(); + } + + // Status and visibility are now handled in publishing info panel by DatasetModeManager + + // Update description display + const descriptionDisplay = document.querySelector( + "#step5 .dataset-description", + ); + if (descriptionDisplay) { + descriptionDisplay.textContent = this.descriptionField + ? this.descriptionField.value + : "No description provided."; + } + + // Update selected items table + this.updateSelectedItemsTable(); + } + + /** + * Update authors display fallback + */ + updateAuthorsDisplayFallback() { + const authorsField = document.getElementById("id_authors"); + const authorsDisplay = document.querySelector("#step5 .dataset-authors"); + + if (authorsField?.value && authorsDisplay) { + try { + const authors = JSON.parse(authorsField.value); + const authorNames = authors.map((author) => { + if (typeof author === "string") { + return author; + } + if (author?.name) { + return author.name; + } + return "Unnamed Author"; + }); + authorsDisplay.textContent = authorNames.join(", "); + } catch (e) { + authorsDisplay.textContent = authorsField.value; + } + } else if (authorsDisplay) { + authorsDisplay.textContent = "No authors specified"; + } + } + + /** + * Update dataset name display + */ + updateDatasetNameDisplay() { + const nameDisplays = document.getElementsByClassName( + "dataset-name-display", + ); + if (this.nameField && nameDisplays.length > 0) { + for (const nameDisplay of Array.from(nameDisplays)) { + nameDisplay.textContent = this.nameField.value || "Untitled Dataset"; + } + } + } + + /** + * Update navigation state + */ + updateNavigation() { + // Update step tabs + for (const [index, tab] of this.stepTabs.entries()) { + tab.classList.remove( + "btn-outline-primary", + "btn-primary", + "active-tab", + "inactive-tab", + ); + + if (index === this.currentStep) { + tab.classList.add("btn-primary", "active-tab"); + } else if (index > this.currentStep) { + tab.classList.add("btn-outline-primary", "inactive-tab"); + } else { + tab.classList.add("btn-primary", "inactive-tab"); + } + } + + // Update content panes + for (const [index, pane] of document + .querySelectorAll(".tab-pane") + .entries()) { + pane.classList.remove("show", "active"); + if (index === this.currentStep) { + pane.classList.add("show", "active"); + } + } + + // Update navigation buttons + if (this.prevBtn) { + if (this.currentStep > 0) { + window.DOMUtils.show(this.prevBtn); + } else { + window.DOMUtils.hide(this.prevBtn); + } + } + + const isValid = this.validateCurrentStep(); + + if (this.nextBtn) { + const isLastStep = this.currentStep === this.steps.length - 1; + if (isLastStep) { + window.DOMUtils.hide(this.nextBtn); + } else { + window.DOMUtils.show(this.nextBtn); + } + this.nextBtn.disabled = !isValid; + } + + if (this.submitBtn) { + // Only show submit button on final step (step 5, index 4) + if (this.currentStep === 4) { + window.DOMUtils.show(this.submitBtn, "display-inline-block"); + this.submitBtn.disabled = !isValid; + } else { + window.DOMUtils.hide(this.submitBtn, "display-inline-block"); + } + } + } + + /** + * Validate current step + * @returns {boolean} Whether current step is valid + */ + validateCurrentStep() { + let isValid = true; + + switch (this.currentStep) { + case 0: + isValid = this.validateDatasetInfo(); + break; + case 1: + isValid = this.validateCapturesSelection(); + break; + case 2: + isValid = this.validateFilesSelection(); + break; + default: + isValid = true; + } + + // Update button states + if (this.nextBtn) { + this.nextBtn.disabled = !isValid; + } + if (this.submitBtn && this.currentStep === 4) { + this.submitBtn.disabled = !isValid; + } + + return isValid; + } + + /** + * Validate dataset info step + * @returns {boolean} Whether dataset info is valid + */ + validateDatasetInfo() { + const nameValue = this.nameField?.value.trim() || ""; + const authorsValue = this.authorsField?.value.trim() || ""; + + // Validate authors JSON and first author name + if (authorsValue) { + try { + const authors = JSON.parse(authorsValue); + if (!Array.isArray(authors) || authors.length === 0) { + return false; + } + + // Check that the first author has a name + const firstAuthor = authors[0]; + if ( + !firstAuthor || + !firstAuthor.name || + firstAuthor.name.trim() === "" + ) { + return false; + } + } catch (e) { + return false; + } + } else { + return false; // Authors field is required + } + + return nameValue !== ""; + } + + /** + * Validate captures selection step + * @returns {boolean} Whether captures selection is valid + */ + validateCapturesSelection() { + return true; // Captures selection is optional + } + + /** + * Validate files selection step + * @returns {boolean} Whether files selection is valid + */ + validateFilesSelection() { + return true; // Files selection is optional + } + + /** + * Handle form submission + * @param {Event} e - Submit event + */ + async handleSubmit(e) { + e.preventDefault(); + + if (!this.validateCurrentStep()) { + return; + } + + // Set loading state + this.setSubmitButtonLoading(true); + + // Update hidden fields + this.updateHiddenFields(); + + // Clear existing errors + this.clearErrors(); + + const formData = new FormData(this.form); + + try { + const response = await window.APIClient.request(this.form.action, { + method: "POST", + body: formData, + }); + + if (response.success) { + window.location.href = response.redirect_url; + } else if (response.errors) { + throw new APIError("Validation failed", 400, response.errors); + } + } catch (error) { + console.error("Error submitting form:", error); + this.handleSubmissionError(error); + } finally { + this.setSubmitButtonLoading(false); + } + } + + /** + * Handle submission error + * @param {Error} error - Error object + */ + async handleSubmissionError(error) { + const errorContainer = document.getElementById("formErrors"); + if (!errorContainer) return; + + try { + // Normalize error context + const context = {}; + + if (error instanceof APIError && error.data.errors) { + // Normalize field errors into list format for template + context.error_list = []; + for (const [field, messages] of Object.entries(error.data.errors)) { + const messageList = Array.isArray(messages) ? messages : [messages]; + for (const msg of messageList) { + context.error_list.push([field, msg]); + } + } + context.show_field_names = true; + } else { + context.message = "An unexpected error occurred. Please try again."; + } + + const messageText = context.message ?? ""; + const templateContext = { + alert_type: "danger", + icon: "exclamation-triangle-fill", + }; + if (context.error_list) { + templateContext.error_list = context.error_list; + templateContext.show_field_names = context.show_field_names; + } + + const success = await window.DOMUtils.showMessage(messageText, { + variant: "danger", + placement: "replace", + target: errorContainer, + presentation: "alert", + templateContext, + }); + if (success) { + window.DOMUtils.show(errorContainer); + errorContainer.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + } catch (err) { + console.error("Error rendering error message:", err); + // Fallback to simple text + errorContainer.textContent = "An error occurred. Please try again."; + window.DOMUtils.show(errorContainer); + } + } + + /** + * Clear form errors + */ + clearErrors() { + const errorContainer = document.getElementById("formErrors"); + if (errorContainer) { + errorContainer.innerHTML = ""; + window.DOMUtils.hide(errorContainer); + } + } + + /** + * Set submit button loading state + * @param {boolean} isLoading - Whether button is loading + */ + setSubmitButtonLoading(isLoading) { + if (!this.submitBtn) return; + + if (isLoading) { + this.submitBtn.dataset.originalText = this.submitBtn.textContent; + this.submitBtn.disabled = true; + this.submitBtn.innerHTML = ` Creating... ` diff --git a/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js b/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js index c85392bb1..4959fa93e 100644 --- a/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js +++ b/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js @@ -3,1320 +3,1270 @@ * Handles dataset editing workflow with pending changes management */ class DatasetEditingHandler extends BaseManager { - /** - * Initialize dataset editing handler - * @param {Object} config - Configuration object - */ - constructor(config) { - super() - this.datasetUuid = config.datasetUuid - this.permissions = config.permissions // PermissionsManager instance - this.currentUserId = config.currentUserId - - // Current assets in dataset - this.currentCaptures = new Map() - this.currentFiles = new Map() - - // Pending changes - this.pendingCaptures = new Map() // key: captureId, value: {action: 'add'|'remove', data: {...}} - this.pendingFiles = new Map() // key: fileId, value: {action: 'add'|'remove', data: {...}} - - // Search handlers - this.capturesSearchHandler = null - this.filesSearchHandler = null - - // Properties that SearchHandler expects from formHandler - this.selectedCaptures = new Set() - this.selectedFiles = new Set() - - // Store initial data - this.initialCaptures = config.initialCaptures || [] - this.initialFiles = config.initialFiles || [] - - this.initializeEventListeners() - this.initializeAuthorsManagement() - - // Load current assets if no initial data provided - if (!this.initialCaptures.length && !this.initialFiles.length) { - this.loadCurrentAssets() - } - } - - /** - * Initialize event listeners - */ - initializeEventListeners() { - // Initialize search handlers if they exist - if (window.AssetSearchHandler) { - this.capturesSearchHandler = new window.AssetSearchHandler({ - searchFormId: "captures-search-form", - searchButtonId: "search-captures", - clearButtonId: "clear-captures-search", - tableBodyId: "captures-table-body", - paginationContainerId: "captures-pagination", - type: "captures", - formHandler: this, - isEditMode: true, - }) - - this.filesSearchHandler = new window.AssetSearchHandler({ - searchFormId: "files-search-form", - searchButtonId: "search-files", - clearButtonId: "clear-files-search", - tableBodyId: "file-tree-table", - paginationContainerId: "files-pagination", - confirmFileSelectionId: "confirm-file-selection", - type: "files", - formHandler: this, - isEditMode: true, - apiEndpoint: window.location.pathname, - }) - - // Initialize captures search to show initial state - if ( - this.capturesSearchHandler && - typeof this.capturesSearchHandler.initializeCapturesSearch === - "function" - ) { - this.capturesSearchHandler.initializeCapturesSearch() - } - - // Populate initial data now that handlers are ready - this.populateFromInitialData( - this.initialCaptures, - this.initialFiles, - ) - } - } - - /** - * Set search handler reference - * @param {Object} searchHandler - Search handler instance - * @param {string} type - Handler type (captures or files) - */ - setSearchHandler(searchHandler, type) { - if (type === "captures") { - this.capturesSearchHandler = searchHandler - // Defer population until SearchHandler is fully ready - Promise.resolve().then(() => { - this.populateSearchHandlerWithInitialData() - }) - } else if (type === "files") { - this.filesSearchHandler = searchHandler - this.filesSearchHandler.updateSelectedFilesList() - } - - // If we have initial data and both handlers are ready, populate the data - if ( - this.capturesSearchHandler && - this.filesSearchHandler && - (this.initialCaptures.length > 0 || this.initialFiles.length > 0) - ) { - this.populateFromInitialData( - this.initialCaptures, - this.initialFiles, - ) - } - } - - /** - * Populate search handler with initial data - */ - populateSearchHandlerWithInitialData() { - // Populate the SearchHandler with initial captures if available - if ( - this.capturesSearchHandler?.selectedCaptures && - this.capturesSearchHandler.selectedCaptureDetails && - this.initialCaptures && - this.initialCaptures.length > 0 - ) { - for (const capture of this.initialCaptures) { - this.capturesSearchHandler.selectedCaptures.add( - capture.id.toString(), - ) - this.capturesSearchHandler.selectedCaptureDetails.set( - capture.id.toString(), - capture, - ) - } - } - // Also populate the DatasetEditingHandler's selectedCaptures set - if (this.initialCaptures && this.initialCaptures.length > 0) { - for (const capture of this.initialCaptures) { - this.selectedCaptures.add(capture.id.toString()) - } - } - } - - /** - * Populate from initial data - * @param {Array} initialCaptures - Initial captures data - * @param {Array} initialFiles - Initial files data - */ - populateFromInitialData(initialCaptures, initialFiles) { - // Populate current captures in the side panel table - this.currentCaptures.clear() - this.populateCurrentCapturesList(initialCaptures) - - // Use the existing SearchHandler to populate captures in the main table - if (this.capturesSearchHandler?.selectedCaptures) { - if (initialCaptures && initialCaptures.length > 0) { - for (const capture of initialCaptures) { - this.capturesSearchHandler.selectedCaptures.add( - capture.id.toString(), - ) - this.capturesSearchHandler.selectedCaptureDetails.set( - capture.id.toString(), - capture, - ) - } - } - } - - // Also populate the DatasetEditingHandler's selectedCaptures set - if (initialCaptures && initialCaptures.length > 0) { - for (const captureId of initialCaptures) { - this.selectedCaptures.add(captureId.toString()) - } - } - - // Populate current files - this.currentFiles.clear() - this.populateCurrentFilesList(initialFiles) - - // Use the existing SearchHandler to populate files for the file browser - if (this.filesSearchHandler) { - if (initialFiles && initialFiles.length > 0) { - for (const file of initialFiles) { - this.filesSearchHandler.selectedFiles.set(file.id, file) - } - } - this.filesSearchHandler.updateSelectedFilesList() - } - - // Add event listeners for remove buttons - this.addRemoveButtonListeners() - - // Initialize file browser modal handlers - this.initializeFileBrowserModal() - } - - /** - * Initialize file browser modal handlers - */ - initializeFileBrowserModal() { - window.AuthorsManager?.bindFileTreeModalHandlers(this) - } - - /** - * Handle file modal show - */ - onFileModalShow() { - // Trigger initial file tree loading if filesSearchHandler exists and tree hasn't been loaded - if (this.filesSearchHandler && !this.filesSearchHandler.currentTree) { - this.filesSearchHandler.handleSearch() - } - } - - /** - * Handle file modal hide - */ - onFileModalHide() { - // Clear any intermediate state if needed - } - - /** - * Populate current captures list - * @param {Array} captures - Captures data - */ - async populateCurrentCapturesList(captures) { - const currentCapturesList = document.getElementById( - "current-captures-list", - ) - const currentCapturesCount = document.querySelector( - ".current-captures-count", - ) - - if (!currentCapturesList) return - - if (captures && captures.length > 0) { - // Normalize for generic table_rows template - const rows = captures.map((capture) => { - this.currentCaptures.set(capture.id, capture) - // Permission logic: co-owners can remove anyone's captures, contributors can only remove their own - const isOwnedByCurrentUser = - capture.owner_id === this.currentUserId - const canRemoveThisCapture = - this.permissions.canRemoveAnyAssets() || - (this.permissions.canRemoveAsset(capture) && - isOwnedByCurrentUser) - - return { - css_class: !canRemoveThisCapture ? "readonly-row" : "", - data_attrs: { "capture-id": capture.id }, - cells: [ - { kind: "text", value: capture.type }, - { kind: "text", value: capture.directory }, - { - kind: "text", - value: - capture.owner?.name || - capture.owner?.email || - "Unknown", - }, - ], - actions: canRemoveThisCapture - ? [ - { - label: "Remove", - css_class: "btn-danger", - extra_class: "mark-for-removal-btn", - data_attrs: { - "capture-id": capture.id, - "capture-type": "capture", - }, - }, - ] - : [{ html: 'N/A' }], - } - }) - - // Render using DOMUtils - const success = await window.DOMUtils.renderTable( - currentCapturesList, - rows, - { - empty_message: "No captures in dataset", - empty_colspan: 4, - }, - ) - - if (!success) { - await window.DOMUtils.showMessage("Error loading captures", { - variant: "danger", - placement: "replace", - target: currentCapturesList, - presentation: "table", - templateContext: { colspan: 4 }, - }) - } - - if (currentCapturesCount) { - currentCapturesCount.textContent = captures.length - } - - // Re-attach event listeners for remove buttons - this.addRemoveButtonListeners() - } else { - currentCapturesList.innerHTML = - 'No captures in dataset' - if (currentCapturesCount) { - currentCapturesCount.textContent = "0" - } - } - } - - /** - * Populate current files list - * @param {Array} files - Files data - */ - async populateCurrentFilesList(files) { - // Use the existing selected-files-table from file_browser.html - const selectedFilesTable = document.getElementById( - "selected-files-table", - ) - const selectedFilesBody = selectedFilesTable?.querySelector("tbody") - const selectedFilesDisplay = document.getElementById( - "selected-files-display", - ) - - if (!selectedFilesBody) return - - if (files && files.length > 0) { - // Normalize for generic table_rows template - const rows = files.map((file) => { - this.currentFiles.set(file.id, file) - - // Permission logic: co-owners can remove anyone's files, contributors can only remove their own - const isOwnedByCurrentUser = - file.owner_id === this.currentUserId - const canRemoveThisFile = - this.permissions.canRemoveAnyAssets() || - (this.permissions.canRemoveAsset(file) && - isOwnedByCurrentUser) - - return { - css_class: !canRemoveThisFile ? "readonly-row" : "", - data_attrs: { "file-id": file.id }, - cells: [ - { kind: "text", value: file.name }, - { kind: "text", value: file.media_type }, - { kind: "text", value: file.relative_path }, - { kind: "text", value: file.size }, - { - kind: "text", - value: - file.owner?.name || - file.owner?.email || - "Unknown", - }, - ], - actions: canRemoveThisFile - ? [ - { - label: "Remove", - css_class: "btn-danger", - extra_class: "mark-for-removal-btn", - data_attrs: { - "file-id": file.id, - "file-type": "file", - }, - }, - ] - : [{ html: 'N/A' }], - } - }) - - // Render using DOMUtils - const success = await window.DOMUtils.renderTable( - selectedFilesBody, - rows, - { - empty_message: "No files in dataset", - empty_colspan: 6, - }, - ) - - if (!success) { - await window.DOMUtils.showMessage("Error loading files", { - variant: "danger", - placement: "replace", - target: selectedFilesBody, - presentation: "table", - templateContext: { colspan: 6 }, - }) - } - - // Update the display input - if (selectedFilesDisplay) { - selectedFilesDisplay.value = `${files.length} file(s) selected` - } - - // Re-attach event listeners for remove buttons - this.addRemoveButtonListeners() - } else { - selectedFilesBody.innerHTML = - 'No files in dataset' - if (selectedFilesDisplay) { - selectedFilesDisplay.value = "0 file(s) selected" - } - } - } - - /** - * Load current assets from API - */ - async loadCurrentAssets() { - if (!this.datasetUuid) return - - try { - const data = await window.APIClient.get( - `/users/dataset-details/?dataset_uuid=${this.datasetUuid}`, - ) - this.populateFromInitialData(data.captures || [], data.files || []) - } catch (error) { - console.error("Error loading current assets:", error) - } - } - - /** - * Add remove button listeners - */ - addRemoveButtonListeners() { - const removeButtons = document.querySelectorAll(".mark-for-removal-btn") - for (const button of removeButtons) { - button.addEventListener("click", (e) => { - e.preventDefault() - const captureId = button.dataset.captureId - const fileId = button.dataset.fileId - if (captureId) { - this.markCaptureForRemoval(captureId) - } else if (fileId) { - this.markFileForRemoval(fileId) - } - }) - } - } - - /** - * Sync strikethrough / checkbox on the capture search results row (step 2). - * @param {string} captureId - * @param {boolean} markedForRemoval - */ - syncCaptureSearchRowRemovalStyle(captureId, markedForRemoval) { - const searchRow = document.querySelector( - `#captures-table-body tr[data-capture-id="${captureId}"]`, - ) - if (!searchRow) return - - if (markedForRemoval) { - searchRow.classList.add("marked-for-removal") - const checkbox = searchRow.querySelector('input[type="checkbox"]') - if (checkbox) { - checkbox.checked = true - } - return - } - - searchRow.classList.remove("marked-for-removal") - const checkbox = searchRow.querySelector('input[type="checkbox"]') - if (checkbox) { - const idStr = captureId.toString() - checkbox.checked = - this.currentCaptures.has(captureId) || - this.currentCaptures.has(idStr) || - this.selectedCaptures.has(idStr) - } - } - - /** - * Mark capture for removal - * @param {string} captureId - Capture ID to mark for removal - */ - markCaptureForRemoval(captureId) { - const capture = - this.currentCaptures.get(captureId) || - this.capturesSearchHandler?.selectedCaptureDetails.get(captureId) - if (!capture) return - - // Check if user has permission to remove this specific capture - const isOwnedByCurrentUser = capture.owner_id === this.currentUserId - const canRemoveThisCapture = - this.permissions.canRemoveAnyAssets() || - (this.permissions.canRemoveAsset(capture) && isOwnedByCurrentUser) - - if (!canRemoveThisCapture) { - console.warn( - `User does not have permission to remove capture ${captureId}`, - ) - return - } - - // Add to pending removals - this.pendingCaptures.set(captureId, { - action: "remove", - data: capture, - }) - - // Update visual state of current captures list - this.updateCurrentCapturesList() - - this.syncCaptureSearchRowRemovalStyle(captureId, true) - - this.updatePendingCapturesList() - - // Update review display - if (window.updateReviewDatasetDisplay) { - window.updateReviewDatasetDisplay() - } - } - - /** - * Mark file for removal - * @param {string} fileId - File ID to mark for removal - */ - markFileForRemoval(fileId) { - const file = this.filesSearchHandler?.selectedFiles.get(fileId) - - if (!file) { - console.warn(`File ${fileId} not found for removal`) - return - } - - // Check if user has permission to remove this specific file - const isOwnedByCurrentUser = file.owner_id === this.currentUserId - const canRemoveThisFile = - this.permissions.canRemoveAnyAssets() || - (this.permissions.canRemoveAsset(file) && isOwnedByCurrentUser) - - if (!canRemoveThisFile) { - console.warn( - `User does not have permission to remove file ${fileId}`, - ) - return - } - - // Add to pending removals - this.pendingFiles.set(fileId, { - action: "remove", - data: file, - }) - - // Update visual state of current files list - this.updateCurrentFilesList() - - // Update review display - if (window.updateReviewDatasetDisplay) { - window.updateReviewDatasetDisplay() - } - - // Also mark in the search results table if visible - const searchRow = document.querySelector( - `#file-tree-table tr[data-file-id="${fileId}"]`, - ) - if (searchRow) { - searchRow.classList.add("marked-for-removal") - const checkbox = searchRow.querySelector('input[type="checkbox"]') - if (checkbox) { - checkbox.checked = true - } - } - - this.updatePendingFilesList() - } - - /** - * Add capture to pending additions - * @param {string} captureId - Capture ID - * @param {Object} captureData - Capture data - */ - addCaptureToPending(captureId, captureData) { - // Check if already in current captures - if (this.currentCaptures.has(captureId)) { - return // Already in dataset - } - - // Check if already in pending additions - if ( - this.pendingCaptures.has(captureId) && - this.pendingCaptures.get(captureId).action === "add" - ) { - return // Already marked for addition - } - - // Add to pending additions - this.pendingCaptures.set(captureId, { - action: "add", - data: captureData, - }) - - // Also add to selectedCaptures set so it shows as checked in search results - this.selectedCaptures.add(captureId.toString()) - - this.updatePendingCapturesList() - - // Update review display - if (window.updateReviewDatasetDisplay) { - window.updateReviewDatasetDisplay() - } - } - - /** - * Add file to pending additions - * @param {string} fileId - File ID - * @param {Object} fileData - File data - */ - addFileToPending(fileId, fileData) { - // Check if already in current files - if (this.currentFiles.has(fileId)) { - return // Already in dataset - } - - // Check if already in pending additions - if ( - this.pendingFiles.has(fileId) && - this.pendingFiles.get(fileId).action === "add" - ) { - return // Already marked for addition - } - - // Add to pending additions - this.pendingFiles.set(fileId, { - action: "add", - data: fileData, - }) - - this.updatePendingFilesList() - - // Update review display - if (window.updateReviewDatasetDisplay) { - window.updateReviewDatasetDisplay() - } - } - - /** - * Update pending captures list - */ - async updatePendingCapturesList() { - const pendingList = document.getElementById("pending-captures-list") - const pendingCount = document.querySelector(".pending-changes-count") - if (!pendingList) return - - await window.DatasetPendingChanges.renderPendingTable(this, { - listElement: pendingList, - countElement: pendingCount, - entries: Array.from(this.pendingCaptures.entries()), - valueKey: "type", - entityAttr: "capture", - emptyMessage: "No pending capture changes", - }) - } - - /** - * Update pending files list - */ - async updatePendingFilesList() { - const pendingList = document.getElementById("pending-files-list") - const pendingCount = document.querySelector( - ".pending-files-changes-count", - ) - if (!pendingList) return - - await window.DatasetPendingChanges.renderPendingTable(this, { - listElement: pendingList, - countElement: pendingCount, - entries: Array.from(this.pendingFiles.entries()), - valueKey: "name", - entityAttr: "file", - emptyMessage: "No pending file changes", - }) - } - - /** - * Add cancel button listeners - */ - addCancelButtonListeners() { - const cancelButtons = document.querySelectorAll(".cancel-change") - for (const button of cancelButtons) { - button.addEventListener("click", (e) => { - e.preventDefault() - const captureId = button.dataset.captureId - const fileId = button.dataset.fileId - const changeType = button.dataset.changeType - - if (changeType === "capture" && captureId) { - this.cancelCaptureChange(captureId) - } else if (changeType === "file" && fileId) { - this.cancelFileChange(fileId) - } - }) - } - } - - /** - * Cancel capture change - * @param {string} captureId - Capture ID - */ - cancelCaptureChange(captureId) { - const change = this.pendingCaptures.get(captureId) - if (!change) return - - this.pendingCaptures.delete(captureId) - - if (change.action === "remove") { - this.updateCurrentCapturesList() - this.syncCaptureSearchRowRemovalStyle(captureId, false) - } else if (change.action === "add") { - this.selectedCaptures.delete(captureId.toString()) - this.syncCaptureSearchRowRemovalStyle(captureId, false) - } - - this.updatePendingCapturesList() - - // Update review display - if (window.updateReviewDatasetDisplay) { - window.updateReviewDatasetDisplay() - } - } - - /** - * Cancel file change - * @param {string} fileId - File ID - */ - cancelFileChange(fileId) { - const change = this.pendingFiles.get(fileId) - if (!change) return - - this.pendingFiles.delete(fileId) - - if (change.action === "remove") { - // Update visual state of current files list - this.updateCurrentFilesList() - } else if (change.action === "add") { - // Remove from SearchHandler's selectedFiles if it exists - if (this.filesSearchHandler) { - this.filesSearchHandler.selectedFiles.delete(fileId) - } - } - - this.updatePendingFilesList() - - // Update review display - if (window.updateReviewDatasetDisplay) { - window.updateReviewDatasetDisplay() - } - } - - /** - * Get pending changes - * @returns {Object} Pending changes object - */ - getPendingChanges() { - return { - captures: Array.from(this.pendingCaptures.entries()), - files: Array.from(this.pendingFiles.entries()), - } - } - - /** - * Check if there are any pending changes - * @returns {boolean} Whether there are pending changes - */ - hasChanges() { - return this.pendingCaptures.size > 0 || this.pendingFiles.size > 0 - } - - /** - * Handle file removal (override for edit mode) - * @param {string} fileId - File ID to remove - */ - handleFileRemoval(fileId) { - // In edit mode: mark for removal instead of actually removing - this.markFileForRemoval(fileId) - } - - /** - * Handle capture removal (override for edit mode) - * @param {string} captureId - Capture ID to remove - */ - handleCaptureRemoval(captureId) { - // In edit mode: mark for removal instead of actually removing - this.markCaptureForRemoval(captureId) - } - - /** - * Handle remove all files (override for edit mode) - */ - handleRemoveAllFiles() { - // In edit mode: mark files for removal only if user has permission - if (this.filesSearchHandler?.selectedFiles) { - let removedCount = 0 - for (const [ - fileId, - file, - ] of this.filesSearchHandler.selectedFiles.entries()) { - // Only mark for removal if user has permission - if (this.permissions.canRemoveAsset(file)) { - this.markFileForRemoval(fileId) - removedCount++ - } - } - - // Disable the remove all files button if any files were marked for removal - if (removedCount > 0) { - const removeAllFilesButton = document.querySelector( - ".remove-all-selected-files-button", - ) - if (removeAllFilesButton) { - removeAllFilesButton.disabled = true - removeAllFilesButton.classList.add("disabled-element") - } - } - } - } - - /** - * Update current files list visual state - * This method only updates the visual state of existing files (e.g., marking for removal) - * It does NOT add new files - those should only appear in pending changes - */ - updateCurrentFilesList() { - const selectedFilesTable = document.getElementById( - "selected-files-table", - ) - const selectedFilesBody = selectedFilesTable?.querySelector("tbody") - if (!selectedFilesBody) return - - // Update visual state of existing rows based on pending changes - const rows = selectedFilesBody.querySelectorAll("tr[data-file-id]") - for (const row of rows) { - const fileId = row.dataset.fileId - const pendingChange = this.pendingFiles.get(fileId) - - if (pendingChange && pendingChange.action === "remove") { - // Mark as pending removal - row.classList.add("marked-for-removal") - const removeButton = row.querySelector(".mark-for-removal-btn") - if (removeButton) { - removeButton.disabled = true - removeButton.classList.add("disabled-element") - } - } else { - // Restore normal state - row.classList.remove("marked-for-removal") - const removeButton = row.querySelector(".mark-for-removal-btn") - if (removeButton) { - removeButton.disabled = false - removeButton.classList.remove("disabled-element") - } - } - } - } - - /** - * Update current captures list visual state - * This method only updates the visual state of existing captures (e.g., marking for removal) - * It does NOT add new captures - those should only appear in pending changes - */ - updateCurrentCapturesList() { - const currentCapturesList = document.getElementById( - "current-captures-list", - ) - if (!currentCapturesList) return - - // Update visual state of existing rows based on pending changes - const rows = currentCapturesList.querySelectorAll("tr[data-capture-id]") - for (const row of rows) { - const captureId = row.dataset.captureId - const pendingChange = this.pendingCaptures.get(captureId) - - if (pendingChange && pendingChange.action === "remove") { - // Mark as pending removal - row.classList.add("marked-for-removal") - const removeButton = row.querySelector(".mark-for-removal-btn") - if (removeButton) { - removeButton.disabled = true - removeButton.classList.add("disabled-element") - } - } else { - // Restore normal state - row.classList.remove("marked-for-removal") - const removeButton = row.querySelector(".mark-for-removal-btn") - if (removeButton) { - removeButton.disabled = false - removeButton.classList.remove("disabled-element") - } - } - } - } - - /** - * Update hidden fields (no-op for editing mode) - */ - updateHiddenFields() { - // This method is called by SearchHandler but not needed for editing mode - // We'll implement it as a no-op since editing mode doesn't use hidden fields - } - - /** - * Handle form submission for edit mode - * @param {Event} e - Submit event - */ - handleSubmit(e) { - e.preventDefault() - - // Collect form data - const formData = new FormData(document.getElementById("datasetForm")) - - // Add pending changes to form data - const pendingChanges = this.getPendingChanges() - - // Add pending captures - const capturesAdd = [] - const capturesRemove = [] - for (const [id, change] of pendingChanges.captures) { - if (change.action === "add") { - capturesAdd.push(id) - } else if (change.action === "remove") { - capturesRemove.push(id) - } - } - - // Add pending files - const filesAdd = [] - const filesRemove = [] - for (const [id, change] of pendingChanges.files) { - if (change.action === "add") { - filesAdd.push(id) - } else if (change.action === "remove") { - filesRemove.push(id) - } - } - - // Add comma-separated lists to form data - if (capturesAdd.length > 0) { - formData.append("captures_add", capturesAdd.join(",")) - } - if (capturesRemove.length > 0) { - formData.append("captures_remove", capturesRemove.join(",")) - } - if (filesAdd.length > 0) { - formData.append("files_add", filesAdd.join(",")) - } - if (filesRemove.length > 0) { - formData.append("files_remove", filesRemove.join(",")) - } - - // Add author changes if they exist - if ( - this.authorChanges && - (this.authorChanges.added.length > 0 || - this.authorChanges.removed.length > 0 || - Object.keys(this.authorChanges.modified).length > 0) - ) { - formData.append( - "author_changes", - JSON.stringify(this.authorChanges), - ) - } - - // Submit the form - this.submitForm(formData) - } - - /** - * Submit the form with pending changes - * @param {FormData} formData - Form data to submit - */ - async submitForm(formData) { - try { - // Show loading state - const submitBtn = document.getElementById("submitForm") - if (submitBtn) { - submitBtn.disabled = true - submitBtn.innerHTML = - 'Updating...' - } - - // Submit form - const response = await fetch(window.location.href, { - method: "POST", - body: formData, - headers: { - "X-CSRFToken": document.querySelector( - "[name=csrfmiddlewaretoken]", - ).value, - }, - }) - - if (response.ok) { - // Success - redirect or show success message - const result = await response.json() - if (result.success) { - // Redirect to dataset list or show success message - window.location.href = - result.redirect_url || "/users/dataset-list/" - } else { - // Show error message - this.showToast( - result.message || - "An error occurred while updating the dataset.", - "error", - ) - } - } else { - // Handle error response - this.showToast( - "An error occurred while updating the dataset.", - "error", - ) - } - } catch (error) { - console.error("Error submitting form:", error) - this.showToast( - "An error occurred while updating the dataset.", - "error", - ) - } finally { - // Restore submit button - const submitBtn = document.getElementById("submitForm") - if (submitBtn) { - submitBtn.disabled = false - submitBtn.innerHTML = "Update Dataset" - } - } - } - - /** - * Initialize authors management for edit mode - */ - initializeAuthorsManagement() { - window.DatasetAuthorsUI?.mount(this, { - mode: "edit", - initialAuthors: this.initialAuthors, - }) - } - - /** - * Update dataset authors with pending changes (for review display) - */ - async updateDatasetAuthors(authorsField) { - const authorsElement = document.querySelector(".dataset-authors") - if (!authorsElement) return - - if (!authorsField) { - // In contributor view, there's no editable authors field, so show original authors - const originalAuthors = - window.datasetModeManager?.originalDatasetData?.authors || [] - const originalAuthorNames = this.formatAuthors(originalAuthors) - authorsElement.textContent = originalAuthorNames - return - } - - try { - // Get current authors with DOM-based stable IDs - const currentAuthorsWithIds = this.getCurrentAuthorsWithDOMIds() - // Get original authors from DatasetModeManager's captured data - const originalAuthors = - window.datasetModeManager?.originalDatasetData?.authors || [] - - // Format original authors for display - const originalAuthorNames = this.formatAuthors(originalAuthors) - - // Always show original value - authorsElement.innerHTML = `${originalAuthorNames}` - - // Calculate changes using DOM-based IDs - const changes = this.calculateAuthorChanges( - originalAuthors, - currentAuthorsWithIds, - ) - - // If there are changes, request server-side rendering - if (changes.length > 0) { - try { - // Normalize for generic change_list template - const normalizedChanges = changes.map((change) => { - if (change.type === "add") { - return { - type: "add", - parts: [ - { text: "Add: " }, - { - text: change.name, - css_class: "text-success", - }, - ], - } - } - if (change.type === "remove") { - return { - type: "remove", - parts: [ - { text: "Remove: " }, - { - text: change.name, - css_class: "text-danger", - }, - ], - } - } - if (change.type === "change") { - // Handle name changes - if ( - change.oldName !== undefined && - change.newName !== undefined - ) { - return { - type: "change", - parts: [ - { text: 'Change Name: "' }, - { text: change.oldName }, - { text: '" → ' }, - { - text: `"${change.newName}"`, - css_class: "text-warning", - }, - ], - } - } - // Handle ORCID changes - if ( - change.oldOrcid !== undefined && - change.newOrcid !== undefined - ) { - const oldOrcidDisplay = change.oldOrcid || "" - const newOrcidDisplay = change.newOrcid || "" - return { - type: "change", - parts: [ - { text: 'Change ORCID ID: "' }, - { text: oldOrcidDisplay }, - { text: '" → ' }, - { - text: `"${newOrcidDisplay}"`, - css_class: "text-warning", - }, - ], - } - } - } - return change - }) - - // Request server to render using generic change_list - const response = await window.APIClient.post( - "/users/render-html/", - { - template: "users/components/change_list.html", - context: { changes: normalizedChanges }, - }, - null, - true, - ) // true = send as JSON - - // Insert the server-rendered HTML - if (response.html) { - authorsElement.insertAdjacentHTML( - "beforeend", - response.html, - ) - } - } catch (error) { - console.error("Error rendering author changes:", error) - // Fallback: show error message - authorsElement.insertAdjacentHTML( - "beforeend", - '
Error loading changes
', - ) - } - } - } catch (e) { - console.error("Error in updateDatasetAuthors:", e) - authorsElement.innerHTML = - 'Error parsing authors.' - } - } - - /** - * Get current authors with DOM-based stable IDs - */ - getCurrentAuthorsWithDOMIds() { - return window.AuthorsManager.getCurrentAuthorsWithDOMIds() - } - - /** - * Capture authors with DOM-based stable IDs - */ - captureAuthorsWithDOMIds(authors) { - const authorsList = document.querySelector(".authors-list") - const authorsWithIds = [] - - if (authorsList) { - // Get author items from DOM - const authorItems = authorsList.querySelectorAll(".author-item") - - for (const [index, authorItem] of authorItems.entries()) { - // Get or create a stable ID for this author item - const authorId = authorItem.id - if (!authorId) { - console.error("❌ Author item missing ID") - return - } - - // Get the author data (either from the authors array or from DOM inputs) - let authorData - if (authors[index]) { - authorData = - typeof authors[index] === "string" - ? { name: authors[index], orcid_id: "" } - : { ...authors[index] } - } else { - // Fallback to DOM inputs if author data is missing - const nameInput = - authorItem.querySelector(".author-name-input") - const orcidInput = authorItem.querySelector( - ".author-orcid-input", - ) - authorData = { - name: nameInput?.value || "", - orcid_id: orcidInput?.value || "", - } - } - - // Add the stable ID - authorData._stableId = authorId - authorsWithIds.push(authorData) - } - } - - return authorsWithIds - } - - /** - * Format authors array into display string - */ - formatAuthors(authors) { - return window.AuthorsManager.formatAuthors(authors) - } - - /** - * Calculate author changes between original and current - */ - calculateAuthorChanges(originalAuthors, currentAuthors) { - const changes = [] - - // Create maps using stable IDs - const originalMap = new Map() - const currentMap = new Map() - - for (const author of originalAuthors) { - if (author._stableId) { - originalMap.set(author._stableId, author) - } - } - - for (const author of currentAuthors) { - if (author._stableId) { - currentMap.set(author._stableId, author) - } - } - - // Find additions (in current but not in original) - for (const [id, author] of currentMap) { - if (!originalMap.has(id)) { - const name = author.name || "Unknown" - changes.push({ type: "add", name }) - } - } - - // Find removals (in original but not in current) - for (const [id, author] of originalMap) { - if (!currentMap.has(id)) { - const name = author.name || "Unknown" - changes.push({ type: "remove", name }) - } - } - - // Find changes (same ID but different content) - for (const [id, currentAuthor] of currentMap) { - const originalAuthor = originalMap.get(id) - if (originalAuthor) { - const currentName = currentAuthor.name || "Unknown" - const originalName = originalAuthor.name || "Unknown" - - const currentOrcid = currentAuthor.orcid_id || "" - const originalOrcid = originalAuthor.orcid_id || "" - - if (currentName !== originalName) { - changes.push({ - type: "change", - oldName: originalName, - newName: currentName, - }) - } - if (currentOrcid !== originalOrcid) { - changes.push({ - type: "change", - oldOrcid: originalOrcid, - newOrcid: currentOrcid, - }) - } - } - } - - return changes - } + /** + * Initialize dataset editing handler + * @param {Object} config - Configuration object + */ + constructor(config) { + super(); + this.datasetUuid = config.datasetUuid; + this.permissions = config.permissions; // PermissionsManager instance + this.currentUserId = config.currentUserId; + + // Current assets in dataset + this.currentCaptures = new Map(); + this.currentFiles = new Map(); + + // Pending changes + this.pendingCaptures = new Map(); // key: captureId, value: {action: 'add'|'remove', data: {...}} + this.pendingFiles = new Map(); // key: fileId, value: {action: 'add'|'remove', data: {...}} + + // Search handlers + this.capturesSearchHandler = null; + this.filesSearchHandler = null; + + // Properties that SearchHandler expects from formHandler + this.selectedCaptures = new Set(); + this.selectedFiles = new Set(); + + // Store initial data + this.initialCaptures = config.initialCaptures || []; + this.initialFiles = config.initialFiles || []; + + this.initializeEventListeners(); + this.initializeAuthorsManagement(); + + // Load current assets if no initial data provided + if (!this.initialCaptures.length && !this.initialFiles.length) { + this.loadCurrentAssets(); + } + } + + /** + * Initialize event listeners + */ + initializeEventListeners() { + // Initialize search handlers if they exist + if (window.AssetSearchHandler) { + this.capturesSearchHandler = new window.AssetSearchHandler({ + searchFormId: "captures-search-form", + searchButtonId: "search-captures", + clearButtonId: "clear-captures-search", + tableBodyId: "captures-table-body", + paginationContainerId: "captures-pagination", + type: "captures", + formHandler: this, + isEditMode: true, + }); + + this.filesSearchHandler = new window.AssetSearchHandler({ + searchFormId: "files-search-form", + searchButtonId: "search-files", + clearButtonId: "clear-files-search", + tableBodyId: "file-tree-root", + paginationContainerId: "files-pagination", + confirmFileSelectionId: "confirm-file-selection", + type: "files", + formHandler: this, + isEditMode: true, + apiEndpoint: window.location.pathname, + }); + + // Initialize captures search to show initial state + if ( + this.capturesSearchHandler && + typeof this.capturesSearchHandler.initializeCapturesSearch === + "function" + ) { + this.capturesSearchHandler.initializeCapturesSearch(); + } + + // Populate initial data now that handlers are ready + this.populateFromInitialData(this.initialCaptures, this.initialFiles); + } + } + + /** + * Set search handler reference + * @param {Object} searchHandler - Search handler instance + * @param {string} type - Handler type (captures or files) + */ + setSearchHandler(searchHandler, type) { + if (type === "captures") { + this.capturesSearchHandler = searchHandler; + // Defer population until SearchHandler is fully ready + Promise.resolve().then(() => { + this.populateSearchHandlerWithInitialData(); + }); + } else if (type === "files") { + this.filesSearchHandler = searchHandler; + this.filesSearchHandler.updateSelectedFilesList(); + } + + // If we have initial data and both handlers are ready, populate the data + if ( + this.capturesSearchHandler && + this.filesSearchHandler && + (this.initialCaptures.length > 0 || this.initialFiles.length > 0) + ) { + this.populateFromInitialData(this.initialCaptures, this.initialFiles); + } + } + + /** + * Populate search handler with initial data + */ + populateSearchHandlerWithInitialData() { + // Populate the SearchHandler with initial captures if available + if ( + this.capturesSearchHandler?.selectedCaptures && + this.capturesSearchHandler.selectedCaptureDetails && + this.initialCaptures && + this.initialCaptures.length > 0 + ) { + for (const capture of this.initialCaptures) { + this.capturesSearchHandler.selectedCaptures.add(capture.id.toString()); + this.capturesSearchHandler.selectedCaptureDetails.set( + capture.id.toString(), + capture, + ); + } + } + // Also populate the DatasetEditingHandler's selectedCaptures set + if (this.initialCaptures && this.initialCaptures.length > 0) { + for (const capture of this.initialCaptures) { + this.selectedCaptures.add(capture.id.toString()); + } + } + } + + /** + * Populate from initial data + * @param {Array} initialCaptures - Initial captures data + * @param {Array} initialFiles - Initial files data + */ + populateFromInitialData(initialCaptures, initialFiles) { + // Populate current captures in the side panel table + this.currentCaptures.clear(); + this.populateCurrentCapturesList(initialCaptures); + + // Use the existing SearchHandler to populate captures in the main table + if (this.capturesSearchHandler?.selectedCaptures) { + if (initialCaptures && initialCaptures.length > 0) { + for (const capture of initialCaptures) { + this.capturesSearchHandler.selectedCaptures.add( + capture.id.toString(), + ); + this.capturesSearchHandler.selectedCaptureDetails.set( + capture.id.toString(), + capture, + ); + } + } + } + + // Also populate the DatasetEditingHandler's selectedCaptures set + if (initialCaptures && initialCaptures.length > 0) { + for (const captureId of initialCaptures) { + this.selectedCaptures.add(captureId.toString()); + } + } + + // Populate current files + this.currentFiles.clear(); + this.populateCurrentFilesList(initialFiles); + + // Use the existing SearchHandler to populate files for the file browser + if (this.filesSearchHandler) { + if (initialFiles && initialFiles.length > 0) { + for (const file of initialFiles) { + this.filesSearchHandler.selectedFiles.set(file.id, file); + } + } + this.filesSearchHandler.updateSelectedFilesList(); + } + + // Add event listeners for remove buttons + this.addRemoveButtonListeners(); + + // Initialize file browser modal handlers + this.initializeFileBrowserModal(); + } + + /** + * Initialize file browser modal handlers + */ + initializeFileBrowserModal() { + window.AuthorsManager?.bindFileTreeModalHandlers(this); + } + + /** + * Handle file modal show + */ + onFileModalShow() { + // Trigger initial file tree loading if filesSearchHandler exists and tree hasn't been loaded + if (this.filesSearchHandler && !this.filesSearchHandler.currentTree) { + this.filesSearchHandler.handleSearch(); + } + } + + /** + * Handle file modal hide + */ + onFileModalHide() { + // Clear any intermediate state if needed + } + + /** + * Populate current captures list + * @param {Array} captures - Captures data + */ + async populateCurrentCapturesList(captures) { + const currentCapturesList = document.getElementById( + "current-captures-list", + ); + const currentCapturesCount = document.querySelector( + ".current-captures-count", + ); + + if (!currentCapturesList) return; + + if (captures && captures.length > 0) { + // Normalize for generic table_rows template + const rows = captures.map((capture) => { + this.currentCaptures.set(capture.id, capture); + // Permission logic: co-owners can remove anyone's captures, contributors can only remove their own + const isOwnedByCurrentUser = capture.owner_id === this.currentUserId; + const canRemoveThisCapture = + this.permissions.canRemoveAnyAssets() || + (this.permissions.canRemoveAsset(capture) && isOwnedByCurrentUser); + + return { + css_class: !canRemoveThisCapture ? "readonly-row" : "", + data_attrs: { "capture-id": capture.id }, + cells: [ + { kind: "text", value: capture.type }, + { kind: "text", value: capture.directory }, + { + kind: "text", + value: capture.owner?.name || capture.owner?.email || "Unknown", + }, + ], + actions: canRemoveThisCapture + ? [ + { + label: "Remove", + css_class: "btn-danger", + extra_class: "mark-for-removal-btn", + data_attrs: { + "capture-id": capture.id, + "capture-type": "capture", + }, + }, + ] + : [{ html: 'N/A' }], + }; + }); + + // Render using DOMUtils + const success = await window.DOMUtils.renderTable( + currentCapturesList, + rows, + { + empty_message: "No captures in dataset", + empty_colspan: 4, + }, + ); + + if (!success) { + await window.DOMUtils.showMessage("Error loading captures", { + variant: "danger", + placement: "replace", + target: currentCapturesList, + presentation: "table", + templateContext: { colspan: 4 }, + }); + } + + if (currentCapturesCount) { + currentCapturesCount.textContent = captures.length; + } + + // Re-attach event listeners for remove buttons + this.addRemoveButtonListeners(); + } else { + currentCapturesList.innerHTML = + 'No captures in dataset'; + if (currentCapturesCount) { + currentCapturesCount.textContent = "0"; + } + } + } + + /** + * Populate current files list + * @param {Array} files - Files data + */ + async populateCurrentFilesList(files) { + // Use the existing selected-files-table from file_browser.html + const selectedFilesTable = document.getElementById("selected-files-table"); + const selectedFilesBody = selectedFilesTable?.querySelector("tbody"); + const selectedFilesDisplay = document.getElementById( + "selected-files-display", + ); + + if (!selectedFilesBody) return; + + if (files && files.length > 0) { + // Normalize for generic table_rows template + const rows = files.map((file) => { + this.currentFiles.set(file.id, file); + + // Permission logic: co-owners can remove anyone's files, contributors can only remove their own + const isOwnedByCurrentUser = file.owner_id === this.currentUserId; + const canRemoveThisFile = + this.permissions.canRemoveAnyAssets() || + (this.permissions.canRemoveAsset(file) && isOwnedByCurrentUser); + + return { + css_class: !canRemoveThisFile ? "readonly-row" : "", + data_attrs: { "file-id": file.id }, + cells: [ + { kind: "text", value: file.name }, + { kind: "text", value: file.media_type }, + { kind: "text", value: file.relative_path }, + { kind: "text", value: file.size }, + { + kind: "text", + value: file.owner?.name || file.owner?.email || "Unknown", + }, + ], + actions: canRemoveThisFile + ? [ + { + label: "Remove", + css_class: "btn-danger", + extra_class: "mark-for-removal-btn", + data_attrs: { + "file-id": file.id, + "file-type": "file", + }, + }, + ] + : [{ html: 'N/A' }], + }; + }); + + // Render using DOMUtils + const success = await window.DOMUtils.renderTable( + selectedFilesBody, + rows, + { + empty_message: "No files in dataset", + empty_colspan: 6, + }, + ); + + if (!success) { + await window.DOMUtils.showMessage("Error loading files", { + variant: "danger", + placement: "replace", + target: selectedFilesBody, + presentation: "table", + templateContext: { colspan: 6 }, + }); + } + + // Update the display input + if (selectedFilesDisplay) { + selectedFilesDisplay.value = `${files.length} file(s) selected`; + } + + // Re-attach event listeners for remove buttons + this.addRemoveButtonListeners(); + } else { + selectedFilesBody.innerHTML = + 'No files in dataset'; + if (selectedFilesDisplay) { + selectedFilesDisplay.value = "0 file(s) selected"; + } + } + } + + /** + * Load current assets from API + */ + async loadCurrentAssets() { + if (!this.datasetUuid) return; + + try { + const data = await window.APIClient.get( + `/users/dataset-details/?dataset_uuid=${this.datasetUuid}`, + ); + this.populateFromInitialData(data.captures || [], data.files || []); + } catch (error) { + console.error("Error loading current assets:", error); + } + } + + /** + * Add remove button listeners + */ + addRemoveButtonListeners() { + const removeButtons = document.querySelectorAll(".mark-for-removal-btn"); + for (const button of removeButtons) { + button.addEventListener("click", (e) => { + e.preventDefault(); + const captureId = button.dataset.captureId; + const fileId = button.dataset.fileId; + if (captureId) { + this.markCaptureForRemoval(captureId); + } else if (fileId) { + this.markFileForRemoval(fileId); + } + }); + } + } + + /** + * Sync strikethrough / checkbox on the capture search results row (step 2). + * @param {string} captureId + * @param {boolean} markedForRemoval + */ + syncCaptureSearchRowRemovalStyle(captureId, markedForRemoval) { + const searchRow = document.querySelector( + `#captures-table-body tr[data-capture-id="${captureId}"]`, + ); + if (!searchRow) return; + + if (markedForRemoval) { + searchRow.classList.add("marked-for-removal"); + const checkbox = searchRow.querySelector('input[type="checkbox"]'); + if (checkbox) { + checkbox.checked = true; + } + return; + } + + searchRow.classList.remove("marked-for-removal"); + const checkbox = searchRow.querySelector('input[type="checkbox"]'); + if (checkbox) { + const idStr = captureId.toString(); + checkbox.checked = + this.currentCaptures.has(captureId) || + this.currentCaptures.has(idStr) || + this.selectedCaptures.has(idStr); + } + } + + /** + * Mark capture for removal + * @param {string} captureId - Capture ID to mark for removal + */ + markCaptureForRemoval(captureId) { + const capture = + this.currentCaptures.get(captureId) || + this.capturesSearchHandler?.selectedCaptureDetails.get(captureId); + if (!capture) return; + + // Check if user has permission to remove this specific capture + const isOwnedByCurrentUser = capture.owner_id === this.currentUserId; + const canRemoveThisCapture = + this.permissions.canRemoveAnyAssets() || + (this.permissions.canRemoveAsset(capture) && isOwnedByCurrentUser); + + if (!canRemoveThisCapture) { + console.warn( + `User does not have permission to remove capture ${captureId}`, + ); + return; + } + + // Add to pending removals + this.pendingCaptures.set(captureId, { + action: "remove", + data: capture, + }); + + // Update visual state of current captures list + this.updateCurrentCapturesList(); + + this.syncCaptureSearchRowRemovalStyle(captureId, true); + + this.updatePendingCapturesList(); + + // Update review display + if (window.updateReviewDatasetDisplay) { + window.updateReviewDatasetDisplay(); + } + } + + /** + * Mark file for removal + * @param {string} fileId - File ID to mark for removal + */ + markFileForRemoval(fileId) { + const file = this.filesSearchHandler?.selectedFiles.get(fileId); + + if (!file) { + console.warn(`File ${fileId} not found for removal`); + return; + } + + // Check if user has permission to remove this specific file + const isOwnedByCurrentUser = file.owner_id === this.currentUserId; + const canRemoveThisFile = + this.permissions.canRemoveAnyAssets() || + (this.permissions.canRemoveAsset(file) && isOwnedByCurrentUser); + + if (!canRemoveThisFile) { + console.warn(`User does not have permission to remove file ${fileId}`); + return; + } + + // Add to pending removals + this.pendingFiles.set(fileId, { + action: "remove", + data: file, + }); + + // Update visual state of current files list + this.updateCurrentFilesList(); + + // Update review display + if (window.updateReviewDatasetDisplay) { + window.updateReviewDatasetDisplay(); + } + + // Also mark in the search results table if visible + const searchRow = document.querySelector( + `#file-tree-root li[data-file-id="${fileId}"]`, + ); + if (searchRow) { + searchRow.classList.add("marked-for-removal"); + const checkbox = searchRow.querySelector('input[type="checkbox"]'); + if (checkbox) { + checkbox.checked = true; + } + } + + this.updatePendingFilesList(); + } + + /** + * Add capture to pending additions + * @param {string} captureId - Capture ID + * @param {Object} captureData - Capture data + */ + addCaptureToPending(captureId, captureData) { + // Check if already in current captures + if (this.currentCaptures.has(captureId)) { + return; // Already in dataset + } + + // Check if already in pending additions + if ( + this.pendingCaptures.has(captureId) && + this.pendingCaptures.get(captureId).action === "add" + ) { + return; // Already marked for addition + } + + // Add to pending additions + this.pendingCaptures.set(captureId, { + action: "add", + data: captureData, + }); + + // Also add to selectedCaptures set so it shows as checked in search results + this.selectedCaptures.add(captureId.toString()); + + this.updatePendingCapturesList(); + + // Update review display + if (window.updateReviewDatasetDisplay) { + window.updateReviewDatasetDisplay(); + } + } + + /** + * Add file to pending additions + * @param {string} fileId - File ID + * @param {Object} fileData - File data + */ + addFileToPending(fileId, fileData) { + // Check if already in current files + if (this.currentFiles.has(fileId)) { + return; // Already in dataset + } + + // Check if already in pending additions + if ( + this.pendingFiles.has(fileId) && + this.pendingFiles.get(fileId).action === "add" + ) { + return; // Already marked for addition + } + + // Add to pending additions + this.pendingFiles.set(fileId, { + action: "add", + data: fileData, + }); + + this.updatePendingFilesList(); + + // Update review display + if (window.updateReviewDatasetDisplay) { + window.updateReviewDatasetDisplay(); + } + } + + /** + * Update pending captures list + */ + async updatePendingCapturesList() { + const pendingList = document.getElementById("pending-captures-list"); + const pendingCount = document.querySelector(".pending-changes-count"); + if (!pendingList) return; + + await window.DatasetPendingChanges.renderPendingTable(this, { + listElement: pendingList, + countElement: pendingCount, + entries: Array.from(this.pendingCaptures.entries()), + valueKey: "type", + entityAttr: "capture", + emptyMessage: "No pending capture changes", + }); + } + + /** + * Update pending files list + */ + async updatePendingFilesList() { + const pendingList = document.getElementById("pending-files-list"); + const pendingCount = document.querySelector(".pending-files-changes-count"); + if (!pendingList) return; + + await window.DatasetPendingChanges.renderPendingTable(this, { + listElement: pendingList, + countElement: pendingCount, + entries: Array.from(this.pendingFiles.entries()), + valueKey: "name", + entityAttr: "file", + emptyMessage: "No pending file changes", + }); + } + + /** + * Add cancel button listeners + */ + addCancelButtonListeners() { + const cancelButtons = document.querySelectorAll(".cancel-change"); + for (const button of cancelButtons) { + button.addEventListener("click", (e) => { + e.preventDefault(); + const captureId = button.dataset.captureId; + const fileId = button.dataset.fileId; + const changeType = button.dataset.changeType; + + if (changeType === "capture" && captureId) { + this.cancelCaptureChange(captureId); + } else if (changeType === "file" && fileId) { + this.cancelFileChange(fileId); + } + }); + } + } + + /** + * Cancel capture change + * @param {string} captureId - Capture ID + */ + cancelCaptureChange(captureId) { + const change = this.pendingCaptures.get(captureId); + if (!change) return; + + this.pendingCaptures.delete(captureId); + + if (change.action === "remove") { + this.updateCurrentCapturesList(); + this.syncCaptureSearchRowRemovalStyle(captureId, false); + } else if (change.action === "add") { + this.selectedCaptures.delete(captureId.toString()); + this.syncCaptureSearchRowRemovalStyle(captureId, false); + } + + this.updatePendingCapturesList(); + + // Update review display + if (window.updateReviewDatasetDisplay) { + window.updateReviewDatasetDisplay(); + } + } + + /** + * Cancel file change + * @param {string} fileId - File ID + */ + cancelFileChange(fileId) { + const change = this.pendingFiles.get(fileId); + if (!change) return; + + this.pendingFiles.delete(fileId); + + if (change.action === "remove") { + // Update visual state of current files list + this.updateCurrentFilesList(); + } else if (change.action === "add") { + // Remove from SearchHandler's selectedFiles if it exists + if (this.filesSearchHandler) { + this.filesSearchHandler.selectedFiles.delete(fileId); + } + } + + this.updatePendingFilesList(); + + // Update review display + if (window.updateReviewDatasetDisplay) { + window.updateReviewDatasetDisplay(); + } + } + + /** + * Get pending changes + * @returns {Object} Pending changes object + */ + getPendingChanges() { + return { + captures: Array.from(this.pendingCaptures.entries()), + files: Array.from(this.pendingFiles.entries()), + }; + } + + /** + * Check if there are any pending changes + * @returns {boolean} Whether there are pending changes + */ + hasChanges() { + return this.pendingCaptures.size > 0 || this.pendingFiles.size > 0; + } + + /** + * Handle file removal (override for edit mode) + * @param {string} fileId - File ID to remove + */ + handleFileRemoval(fileId) { + // In edit mode: mark for removal instead of actually removing + this.markFileForRemoval(fileId); + } + + /** + * Handle capture removal (override for edit mode) + * @param {string} captureId - Capture ID to remove + */ + handleCaptureRemoval(captureId) { + // In edit mode: mark for removal instead of actually removing + this.markCaptureForRemoval(captureId); + } + + /** + * Handle remove all files (override for edit mode) + */ + handleRemoveAllFiles() { + // In edit mode: mark files for removal only if user has permission + if (this.filesSearchHandler?.selectedFiles) { + let removedCount = 0; + for (const [ + fileId, + file, + ] of this.filesSearchHandler.selectedFiles.entries()) { + // Only mark for removal if user has permission + if (this.permissions.canRemoveAsset(file)) { + this.markFileForRemoval(fileId); + removedCount++; + } + } + + // Disable the remove all files button if any files were marked for removal + if (removedCount > 0) { + const removeAllFilesButton = document.querySelector( + ".remove-all-selected-files-button", + ); + if (removeAllFilesButton) { + removeAllFilesButton.disabled = true; + removeAllFilesButton.classList.add("disabled-element"); + } + } + } + } + + /** + * Update current files list visual state + * This method only updates the visual state of existing files (e.g., marking for removal) + * It does NOT add new files - those should only appear in pending changes + */ + updateCurrentFilesList() { + const selectedFilesTable = document.getElementById("selected-files-table"); + const selectedFilesBody = selectedFilesTable?.querySelector("tbody"); + if (!selectedFilesBody) return; + + // Update visual state of existing rows based on pending changes + const rows = selectedFilesBody.querySelectorAll("tr[data-file-id]"); + for (const row of rows) { + const fileId = row.dataset.fileId; + const pendingChange = this.pendingFiles.get(fileId); + + if (pendingChange && pendingChange.action === "remove") { + // Mark as pending removal + row.classList.add("marked-for-removal"); + const removeButton = row.querySelector(".mark-for-removal-btn"); + if (removeButton) { + removeButton.disabled = true; + removeButton.classList.add("disabled-element"); + } + } else { + // Restore normal state + row.classList.remove("marked-for-removal"); + const removeButton = row.querySelector(".mark-for-removal-btn"); + if (removeButton) { + removeButton.disabled = false; + removeButton.classList.remove("disabled-element"); + } + } + } + } + + /** + * Update current captures list visual state + * This method only updates the visual state of existing captures (e.g., marking for removal) + * It does NOT add new captures - those should only appear in pending changes + */ + updateCurrentCapturesList() { + const currentCapturesList = document.getElementById( + "current-captures-list", + ); + if (!currentCapturesList) return; + + // Update visual state of existing rows based on pending changes + const rows = currentCapturesList.querySelectorAll("tr[data-capture-id]"); + for (const row of rows) { + const captureId = row.dataset.captureId; + const pendingChange = this.pendingCaptures.get(captureId); + + if (pendingChange && pendingChange.action === "remove") { + // Mark as pending removal + row.classList.add("marked-for-removal"); + const removeButton = row.querySelector(".mark-for-removal-btn"); + if (removeButton) { + removeButton.disabled = true; + removeButton.classList.add("disabled-element"); + } + } else { + // Restore normal state + row.classList.remove("marked-for-removal"); + const removeButton = row.querySelector(".mark-for-removal-btn"); + if (removeButton) { + removeButton.disabled = false; + removeButton.classList.remove("disabled-element"); + } + } + } + } + + /** + * Update hidden fields (no-op for editing mode) + */ + updateHiddenFields() { + // This method is called by SearchHandler but not needed for editing mode + // We'll implement it as a no-op since editing mode doesn't use hidden fields + } + + /** + * Handle form submission for edit mode + * @param {Event} e - Submit event + */ + handleSubmit(e) { + e.preventDefault(); + + // Collect form data + const formData = new FormData(document.getElementById("datasetForm")); + + // Add pending changes to form data + const pendingChanges = this.getPendingChanges(); + + // Add pending captures + const capturesAdd = []; + const capturesRemove = []; + for (const [id, change] of pendingChanges.captures) { + if (change.action === "add") { + capturesAdd.push(id); + } else if (change.action === "remove") { + capturesRemove.push(id); + } + } + + // Add pending files + const filesAdd = []; + const filesRemove = []; + for (const [id, change] of pendingChanges.files) { + if (change.action === "add") { + filesAdd.push(id); + } else if (change.action === "remove") { + filesRemove.push(id); + } + } + + // Add comma-separated lists to form data + if (capturesAdd.length > 0) { + formData.append("captures_add", capturesAdd.join(",")); + } + if (capturesRemove.length > 0) { + formData.append("captures_remove", capturesRemove.join(",")); + } + if (filesAdd.length > 0) { + formData.append("files_add", filesAdd.join(",")); + } + if (filesRemove.length > 0) { + formData.append("files_remove", filesRemove.join(",")); + } + + // Add author changes if they exist + if ( + this.authorChanges && + (this.authorChanges.added.length > 0 || + this.authorChanges.removed.length > 0 || + Object.keys(this.authorChanges.modified).length > 0) + ) { + formData.append("author_changes", JSON.stringify(this.authorChanges)); + } + + // Submit the form + this.submitForm(formData); + } + + /** + * Submit the form with pending changes + * @param {FormData} formData - Form data to submit + */ + async submitForm(formData) { + try { + // Show loading state + const submitBtn = document.getElementById("submitForm"); + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.innerHTML = + 'Updating...'; + } + + // Submit form + const response = await fetch(window.location.href, { + method: "POST", + body: formData, + headers: { + "X-CSRFToken": document.querySelector("[name=csrfmiddlewaretoken]") + .value, + }, + }); + + if (response.ok) { + // Success - redirect or show success message + const result = await response.json(); + if (result.success) { + // Redirect to dataset list or show success message + window.location.href = result.redirect_url || "/users/dataset-list/"; + } else { + // Show error message + this.showToast( + result.message || "An error occurred while updating the dataset.", + "error", + ); + } + } else { + // Handle error response + this.showToast( + "An error occurred while updating the dataset.", + "error", + ); + } + } catch (error) { + console.error("Error submitting form:", error); + this.showToast( + "An error occurred while updating the dataset.", + "error", + ); + } finally { + // Restore submit button + const submitBtn = document.getElementById("submitForm"); + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.innerHTML = "Update Dataset"; + } + } + } + + /** + * Initialize authors management for edit mode + */ + initializeAuthorsManagement() { + window.DatasetAuthorsUI?.mount(this, { + mode: "edit", + initialAuthors: this.initialAuthors, + }); + } + + /** + * Update dataset authors with pending changes (for review display) + */ + async updateDatasetAuthors(authorsField) { + const authorsElement = document.querySelector(".dataset-authors"); + if (!authorsElement) return; + + if (!authorsField) { + // In contributor view, there's no editable authors field, so show original authors + const originalAuthors = + window.datasetModeManager?.originalDatasetData?.authors || []; + const originalAuthorNames = this.formatAuthors(originalAuthors); + authorsElement.textContent = originalAuthorNames; + return; + } + + try { + // Get current authors with DOM-based stable IDs + const currentAuthorsWithIds = this.getCurrentAuthorsWithDOMIds(); + // Get original authors from DatasetModeManager's captured data + const originalAuthors = + window.datasetModeManager?.originalDatasetData?.authors || []; + + // Format original authors for display + const originalAuthorNames = this.formatAuthors(originalAuthors); + + // Always show original value + authorsElement.innerHTML = `${originalAuthorNames}`; + + // Calculate changes using DOM-based IDs + const changes = this.calculateAuthorChanges( + originalAuthors, + currentAuthorsWithIds, + ); + + // If there are changes, request server-side rendering + if (changes.length > 0) { + try { + // Normalize for generic change_list template + const normalizedChanges = changes.map((change) => { + if (change.type === "add") { + return { + type: "add", + parts: [ + { text: "Add: " }, + { text: change.name, css_class: "text-success" }, + ], + }; + } + if (change.type === "remove") { + return { + type: "remove", + parts: [ + { text: "Remove: " }, + { text: change.name, css_class: "text-danger" }, + ], + }; + } + if (change.type === "change") { + // Handle name changes + if ( + change.oldName !== undefined && + change.newName !== undefined + ) { + return { + type: "change", + parts: [ + { text: 'Change Name: "' }, + { text: change.oldName }, + { text: '" → ' }, + { text: `"${change.newName}"`, css_class: "text-warning" }, + ], + }; + } + // Handle ORCID changes + if ( + change.oldOrcid !== undefined && + change.newOrcid !== undefined + ) { + const oldOrcidDisplay = change.oldOrcid || ""; + const newOrcidDisplay = change.newOrcid || ""; + return { + type: "change", + parts: [ + { text: 'Change ORCID ID: "' }, + { text: oldOrcidDisplay }, + { text: '" → ' }, + { text: `"${newOrcidDisplay}"`, css_class: "text-warning" }, + ], + }; + } + } + return change; + }); + + // Request server to render using generic change_list + const response = await window.APIClient.post( + "/users/render-html/", + { + template: "users/components/change_list.html", + context: { changes: normalizedChanges }, + }, + null, + true, + ); // true = send as JSON + + // Insert the server-rendered HTML + if (response.html) { + authorsElement.insertAdjacentHTML("beforeend", response.html); + } + } catch (error) { + console.error("Error rendering author changes:", error); + // Fallback: show error message + authorsElement.insertAdjacentHTML( + "beforeend", + '
Error loading changes
', + ); + } + } + } catch (e) { + console.error("Error in updateDatasetAuthors:", e); + authorsElement.innerHTML = + 'Error parsing authors.'; + } + } + + /** + * Get current authors with DOM-based stable IDs + */ + getCurrentAuthorsWithDOMIds() { + return window.AuthorsManager.getCurrentAuthorsWithDOMIds(); + } + + /** + * Capture authors with DOM-based stable IDs + */ + captureAuthorsWithDOMIds(authors) { + const authorsList = document.querySelector(".authors-list"); + const authorsWithIds = []; + + if (authorsList) { + // Get author items from DOM + const authorItems = authorsList.querySelectorAll(".author-item"); + + for (const [index, authorItem] of authorItems.entries()) { + // Get or create a stable ID for this author item + const authorId = authorItem.id; + if (!authorId) { + console.error("❌ Author item missing ID"); + return; + } + + // Get the author data (either from the authors array or from DOM inputs) + let authorData; + if (authors[index]) { + authorData = + typeof authors[index] === "string" + ? { name: authors[index], orcid_id: "" } + : { ...authors[index] }; + } else { + // Fallback to DOM inputs if author data is missing + const nameInput = authorItem.querySelector(".author-name-input"); + const orcidInput = authorItem.querySelector(".author-orcid-input"); + authorData = { + name: nameInput?.value || "", + orcid_id: orcidInput?.value || "", + }; + } + + // Add the stable ID + authorData._stableId = authorId; + authorsWithIds.push(authorData); + } + } + + return authorsWithIds; + } + + /** + * Format authors array into display string + */ + formatAuthors(authors) { + return window.AuthorsManager.formatAuthors(authors); + } + + /** + * Calculate author changes between original and current + */ + calculateAuthorChanges(originalAuthors, currentAuthors) { + const changes = []; + + // Create maps using stable IDs + const originalMap = new Map(); + const currentMap = new Map(); + + for (const author of originalAuthors) { + if (author._stableId) { + originalMap.set(author._stableId, author); + } + } + + for (const author of currentAuthors) { + if (author._stableId) { + currentMap.set(author._stableId, author); + } + } + + // Find additions (in current but not in original) + for (const [id, author] of currentMap) { + if (!originalMap.has(id)) { + const name = author.name || "Unknown"; + changes.push({ type: "add", name }); + } + } + + // Find removals (in original but not in current) + for (const [id, author] of originalMap) { + if (!currentMap.has(id)) { + const name = author.name || "Unknown"; + changes.push({ type: "remove", name }); + } + } + + // Find changes (same ID but different content) + for (const [id, currentAuthor] of currentMap) { + const originalAuthor = originalMap.get(id); + if (originalAuthor) { + const currentName = currentAuthor.name || "Unknown"; + const originalName = originalAuthor.name || "Unknown"; + + const currentOrcid = currentAuthor.orcid_id || ""; + const originalOrcid = originalAuthor.orcid_id || ""; + + if (currentName !== originalName) { + changes.push({ + type: "change", + oldName: originalName, + newName: currentName, + }); + } + if (currentOrcid !== originalOrcid) { + changes.push({ + type: "change", + oldOrcid: originalOrcid, + newOrcid: currentOrcid, + }); + } + } + } + + return changes; + } } // Make class available globally diff --git a/gateway/sds_gateway/static/js/search/AssetSearchHandler.js b/gateway/sds_gateway/static/js/search/AssetSearchHandler.js index 8f47e4ea8..b5ed959d6 100644 --- a/gateway/sds_gateway/static/js/search/AssetSearchHandler.js +++ b/gateway/sds_gateway/static/js/search/AssetSearchHandler.js @@ -17,16 +17,37 @@ function getConfiguredSearchElements(config) { } class AssetSearchHandler { - /** - * @param {object} target - * @param {object} config - */ - static applySearchCoreElements(target, config) { - const searchEls = getConfiguredSearchElements(config) - target.searchForm = searchEls.searchForm - target.searchButton = searchEls.searchButton - target.clearButton = searchEls.clearButton - } + static FILE_TREE_ROOT_ID = "file-tree-root"; + static FILE_TREE_FILE_CHECKBOX_SELECTOR = + '#file-tree-root input[name="files"]'; + + /** + * @returns {HTMLElement|null} + */ + getFileTreeRoot() { + return document.getElementById(AssetSearchHandler.FILE_TREE_ROOT_ID); + } + + /** + * @returns {HTMLInputElement[]} + */ + getVisibleFileCheckboxes() { + return Array.from( + document.querySelectorAll( + `${AssetSearchHandler.FILE_TREE_FILE_CHECKBOX_SELECTOR}:not(:disabled)`, + ), + ).filter((checkbox) => checkbox.offsetParent !== null); + } + /** + * @param {object} target + * @param {object} config + */ + static applySearchCoreElements(target, config) { + const searchEls = getConfiguredSearchElements(config); + target.searchForm = searchEls.searchForm; + target.searchButton = searchEls.searchButton; + target.clearButton = searchEls.clearButton; + } /** * Build URLSearchParams from config specs ({ param, elementId } or { param, el, get }). @@ -373,11 +394,9 @@ class AssetSearchHandler { ) if (!selectAllCheckbox) return - selectAllCheckbox.addEventListener("change", () => { - const isChecked = selectAllCheckbox.checked - const fileCheckboxes = document.querySelectorAll( - '#file-tree-table tbody input[type="checkbox"]', - ) + selectAllCheckbox.addEventListener("change", () => { + const isChecked = selectAllCheckbox.checked; + const fileCheckboxes = this.getVisibleFileCheckboxes(); for (const checkbox of fileCheckboxes) { if (checkbox.checked !== isChecked) { @@ -397,20 +416,20 @@ class AssetSearchHandler { ) if (!removeAllButton) return - removeAllButton.addEventListener("click", () => { - // Check if formHandler has a custom removal handler for edit mode - if (this.formHandler?.handleRemoveAllFiles) { - this.formHandler.handleRemoveAllFiles() - } else { - // Default behavior for create mode - // Deselect all files - const fileCheckboxes = document.querySelectorAll( - '#file-tree-table tbody input[type="checkbox"]', - ) - for (const checkbox of fileCheckboxes) { - checkbox.checked = false - checkbox.dispatchEvent(new Event("change")) - } + removeAllButton.addEventListener("click", () => { + // Check if formHandler has a custom removal handler for edit mode + if (this.formHandler?.handleRemoveAllFiles) { + this.formHandler.handleRemoveAllFiles(); + } else { + // Default behavior for create mode + // Deselect all files + const fileCheckboxes = document.querySelectorAll( + AssetSearchHandler.FILE_TREE_FILE_CHECKBOX_SELECTOR, + ); + for (const checkbox of fileCheckboxes) { + checkbox.checked = false; + checkbox.dispatchEvent(new Event("change")); + } this.selectedFiles.clear() this.updateSelectedFilesList() @@ -500,929 +519,851 @@ class AssetSearchHandler { - ` - capturesContainer.appendChild(selectedPane) - } - - /** - * Update selected captures pane - */ - async updateSelectedCapturesPane() { - const selectedList = document.getElementById("selected-captures-list") - const countBadge = document.querySelector(".selected-captures-count") - if (!selectedList || !countBadge || !this.formHandler) return - - const selectedCaptures = this.formHandler.selectedCaptures - countBadge.textContent = `${selectedCaptures.size} selected` - - // Prepare captures data for server-side rendering - const capturesData = Array.from(selectedCaptures).map((captureId) => { - const data = this.selectedCaptureDetails.get(captureId) || { - type: "Unknown", - directory: "Unknown", - } - return { - id: captureId, - type: data.type, - directory: data.directory, - } - }) - - await this.renderSelectedCapturesTable(selectedList, capturesData) - - // Add remove handlers after async render (DOM must contain buttons) - const removeSelectedButtons = selectedList.querySelectorAll( - ".remove-selected-capture", - ) - for (const button of removeSelectedButtons) { - button.addEventListener("click", (e) => { - e.preventDefault() - e.stopPropagation() - const captureId = button.dataset.id - - // Check if formHandler has a custom removal handler for edit mode - if (this.formHandler?.handleCaptureRemoval) { - this.formHandler.handleCaptureRemoval(captureId) - } else { - // Default behavior for create mode - this.formHandler.selectedCaptures.delete(captureId) - this.selectedCaptureDetails.delete(captureId) - - // Update checkbox if visible - const checkbox = document.querySelector( - `input[name="captures"][value="${captureId}"]`, - ) - if (checkbox) { - checkbox.checked = false - checkbox.closest("tr").classList.remove("table-warning") - } - - void this.updateSelectedCapturesPane() - this.formHandler.updateHiddenFields() - } - }) - } - } - - /** - * Render selected captures table asynchronously - */ - async renderSelectedCapturesTable(selectedList, capturesData) { - // Normalize for generic table_rows template - const rows = capturesData.map((capture) => ({ - data_attrs: { "capture-id": capture.id }, - cells: [ - { kind: "text", value: capture.type }, - { kind: "text", value: capture.directory }, - ], - actions: [ - { - label: "Remove", - icon: "bi-x", - css_class: "btn-danger", - extra_class: "remove-selected-capture", - data_attrs: { id: capture.id }, - }, - ], - })) - - // Use the generic table_rows template via DOMUtils - const success = await window.DOMUtils.renderTable(selectedList, rows, { - empty_message: "No captures selected", - empty_colspan: 3, - }) - - if (!success) { - console.error("Error rendering selected captures table") - } - } - - /** - * Fetch captures data - * @param {Object} params - Search parameters - * @returns {Promise} Captures data - */ - async fetchCaptures(params = {}) { - try { - const searchParams = new URLSearchParams() - - // Add all params to the search parameters - for (const [key, value] of Object.entries(params)) { - if (value) { - searchParams.append(key, value) - } - } - - // Always add the search_captures parameter - searchParams.append("search_captures", "true") - - const data = await window.APIClient.request( - `${this.config.apiEndpoint}?${searchParams.toString()}`, - { - headers: { - "X-Requested-With": "XMLHttpRequest", - }, - }, - ) - - // APIClient.request already returns parsed JSON data - return data - } catch (error) { - console.error("Error fetching captures:", error) - return { results: [], pagination: {} } - } - } - - /** - * Fetch files data - * @param {Object} params - Search parameters - * @returns {Promise} Files data - */ - async fetchFiles(params = {}) { - try { - const searchParams = new URLSearchParams(params) - const data = await window.APIClient.request( - `${this.config.apiEndpoint}?${searchParams.toString()}&search_files=true`, - { - headers: { - "X-Requested-With": "XMLHttpRequest", - }, - }, - ) - - // APIClient.request already returns parsed JSON data - return data - } catch (error) { - console.error("Error fetching files:", error) - return { tree: {}, pagination: {} } - } - } - - /** - * Update captures table - * @param {Object} data - Captures data - */ - async updateCapturesTable(data) { - const tbody = document.querySelector("#captures-table tbody") - - // Update the results count - this.updateResultsCount(data.results.length) - - // Transform captures data for table_rows.html template - const rows = data.results.map((capture) => { - const captureIdStr = capture.id.toString() - const inExistingDataset = - this.isEditMode && - this.formHandler?.currentCaptures && - (this.formHandler.currentCaptures.has(capture.id) || - this.formHandler.currentCaptures.has(captureIdStr)) - const isSelected = - inExistingDataset || - this.formHandler?.selectedCaptures?.has(captureIdStr) - const isOwnedByCurrentUser = - capture.owner_id === this.formHandler?.currentUserId - const canSelect = isOwnedByCurrentUser - // Edit only: captures already in the dataset stay checked and locked - const checkboxDisabled = - !canSelect || (this.isEditMode && inExistingDataset) - const ownerName = capture.owner - ? capture.owner.name || capture.owner.email || "-" - : "-" - const createdAt = new Date(capture.created_at).toLocaleDateString( - "en-US", - { - month: "2-digit", - day: "2-digit", - year: "numeric", - }, - ) - - return { - id: capture.id, - css_class: `capture-row${isSelected ? " table-warning" : ""}${checkboxDisabled ? " readonly-row" : ""}${inExistingDataset ? " capture-in-dataset" : ""}`, - data_attrs: { - "capture-id": capture.id, - }, - cells: [ - { - kind: "html", - tag: "input", - class: "form-check-input capture-checkbox", - name: "captures", - tag_attrs: { - type: "checkbox", - checked: isSelected, - disabled: checkboxDisabled, - value: capture.id, - }, - data_attrs: { - "capture-type": capture.type, - "capture-directory": capture.directory, - "capture-channel": capture.channel, - "capture-scan-group": capture.scan_group, - "capture-created-at": capture.created_at, - "capture-owner-id": capture.owner_id, - "capture-owner-name": ownerName, - }, - }, - { kind: "text", value: capture.type }, - { kind: "text", value: capture.directory }, - { kind: "text", value: capture.channel }, - { kind: "text", value: capture.scan_group }, - { kind: "text", value: ownerName }, - { kind: "text", value: createdAt }, - ], - } - }) - - // Render using DOMUtils - const success = await window.DOMUtils.renderTable(tbody, rows, { - empty_message: "No captures found", - empty_colspan: 7, - }) - - if (success) { - // Attach event handlers to rendered rows - this.attachCaptureRowHandlers(tbody) - } else { - await window.DOMUtils.showMessage("Error loading captures", { - variant: "danger", - placement: "replace", - target: tbody, - presentation: "table", - templateContext: { colspan: 7 }, - }) - } - - // Update pagination with current filters - this.updatePagination("captures", data.pagination) - - // Update selected captures pane - await this.updateSelectedCapturesPane() - } - - /** - * Attach event handlers to capture table rows - * @param {Element} tbody - Table body element - */ - attachCaptureRowHandlers(tbody) { - const rows = tbody.querySelectorAll("tr[data-capture-id]") - - for (const row of rows) { - const checkbox = row.querySelector("input.capture-checkbox") - if (!checkbox) continue - - const captureId = checkbox.value - const captureData = { - type: checkbox.dataset.captureType, - directory: checkbox.dataset.captureDirectory, - channel: checkbox.dataset.captureChannel, - scan_group: checkbox.dataset.captureScanGroup, - created_at: checkbox.dataset.captureCreatedAt, - owner_id: checkbox.dataset.captureOwnerId, - owner_name: checkbox.dataset.captureOwnerName, - } - - const handleSelection = (e) => { - if (checkbox.disabled) { - e.preventDefault() - e.stopPropagation() - return - } - - if (e.target.type !== "checkbox") { - checkbox.checked = !checkbox.checked - } - - if (checkbox.checked) { - // Check if this is an editing handler - if (this.formHandler.addCaptureToPending) { - this.formHandler.addCaptureToPending( - captureId, - captureData, - ) - } else { - // Regular selection for creation - this.formHandler.selectedCaptures.add(captureId) - row.classList.add("table-warning") - this.selectedCaptureDetails.set(captureId, captureData) - this.formHandler.updateHiddenFields() - void this.updateSelectedCapturesPane() - } - - if (this.formHandler.updateCurrentCapturesList) { - this.formHandler.updateCurrentCapturesList() - } - } else { - if (this.formHandler.addCaptureToPending) { - this.formHandler.cancelCaptureChange(captureId) - } else { - this.formHandler.selectedCaptures.delete(captureId) - row.classList.remove("table-warning") - this.selectedCaptureDetails.delete(captureId) - this.formHandler.updateHiddenFields() - void this.updateSelectedCapturesPane() - } - - if (this.formHandler.updateCurrentCapturesList) { - this.formHandler.updateCurrentCapturesList() - } - } - } - - // Add click handler for the row - row.addEventListener("click", handleSelection) - - // Add specific handler for checkbox to prevent double-triggering - checkbox.addEventListener("change", (e) => { - e.stopPropagation() - handleSelection(e) - }) - } - } - - /** - * Update pagination - * @param {string} type - Type of pagination (captures or files) - * @param {Object} pagination - Pagination data - */ - async updatePagination(type, pagination) { - const paginationContainer = document.querySelector( - `#${type}-pagination`, - ) - if (!paginationContainer) return - - const success = await window.DOMUtils.renderPagination( - paginationContainer, - pagination, - ) - - if (success && pagination && pagination.num_pages > 1) { - // Attach click handlers after rendering - this.attachPaginationHandlers(type, paginationContainer) - } - } - - /** - * Attach pagination click handlers - * @param {string} type - Type of pagination (captures or files) - * @param {Element} container - Pagination container element - */ - attachPaginationHandlers(type, container) { - const links = container.querySelectorAll("a.page-link") - for (const link of links) { - link.addEventListener("click", async (e) => { - e.preventDefault() - const target = e.target.closest("a.page-link") - const page = target?.dataset.page - - if (type === "captures") { - const params = { - ...this.currentFilters, - page: page, - } - const data = await this.fetchCaptures(params) - this.updateCapturesTable(data) - } else { - const data = await this.fetchFiles( - this.getFileSearchParams({ page }), - ) - this.updateFilesTable(data) - } - }) - } - } - - /** - * Handle search - */ - async handleSearch() { - try { - // Get all input elements within the search container - const searchContainer = this.searchForm - if (!searchContainer) { - console.error("Search container not found:", this.searchForm) - return - } - const params = new URLSearchParams() - - // Get all form inputs within the container - const inputs = searchContainer.querySelectorAll( - "input, select, textarea", - ) - for (const input of inputs) { - if (input.value) { - params.append(input.name, input.value) - } - } - - // Add the search type parameter - params.append( - this.type === "captures" ? "search_captures" : "search_files", - "true", - ) - - const data = await window.APIClient.request( - `${this.config.apiEndpoint}?${params.toString()}`, - { - headers: { - "X-Requested-With": "XMLHttpRequest", - }, - }, - ) - - // APIClient.request already returns parsed JSON data - - if (this.type === "captures") { - this.updateCapturesTable(data) - } else { - // Reset select all checkbox - const selectAllCheckbox = document.getElementById( - "select-all-files-checkbox", - ) - if (selectAllCheckbox) { - selectAllCheckbox.checked = false - } - if (data.tree) { - // Update file extension select options while preserving current selection - const extensionSelect = - document.getElementById("file-extension") - if (extensionSelect && data.extension_choices) { - const currentValue = extensionSelect.value - await window.DOMUtils.renderSelectOptions( - extensionSelect, - data.extension_choices, - currentValue, - ) - } - - // Restore search values if they exist - if (data.search_values) { - const fileNameInput = - document.getElementById("file-name") - const directoryInput = - document.getElementById("file-directory") - - if (fileNameInput) { - fileNameInput.value = - data.search_values.file_name || "" - } - if (extensionSelect) { - extensionSelect.value = - data.search_values.file_extension || "" - } - if (directoryInput) { - directoryInput.value = - data.search_values.directory || "" - } - } - - const searchTermEntered = - data.search_values.file_name || - data.search_values.directory || - data.search_values.file_extension - - this.renderFileTree( - data.tree, - null, - 0, - "", - searchTermEntered, - ) - - // Initialize select all checkbox handler for the current file tree - this.initializeSelectAllCheckbox() - - // Initialize remove all button handler for the current file tree - this.initializeRemoveAllButton() - } - } - - if (data.pagination) { - this.updatePagination(this.type, data.pagination) - } - } catch (error) { - console.error("Error during search:", error) - this.showError( - "An error occurred during the search. Please try again.", - ) - } - } - - /** - * Handle clear - */ - handleClear() { - // Clear all form inputs - const searchContainer = this.searchForm - if (!searchContainer) { - console.error("Search container not found:", this.searchForm) - return - } - - const inputs = searchContainer.querySelectorAll( - "input, select, textarea", - ) - for (const input of inputs) { - input.value = "" - } - - // Trigger a new search with empty parameters - this.handleSearch() - } - - /** - * Update selected files list - */ - updateSelectedFilesList() { - // Update form handler's selectedFiles with current selection (create mode only) - if (this.formHandler && !this.isEditMode) { - // Convert Map entries to array of file objects with IDs - const fileList = Array.from(this.selectedFiles.entries()).map( - ([id, file]) => ({ ...file, id: id }), - ) - this.formHandler.selectedFiles = new Set(fileList) - } - - // Update selected files display input - const selectedFilesDisplay = document.getElementById( - "selected-files-display", - ) - if (selectedFilesDisplay) { - selectedFilesDisplay.value = `${this.selectedFiles.size} file(s) selected` - } - - // Update Remove All button state - const removeAllButton = document.getElementById( - "remove-all-selected-files-button", - ) - if (removeAllButton) { - removeAllButton.disabled = this.selectedFiles.size === 0 - } - - // Update selected files table if it exists (only in create mode) - if (!this.isEditMode) { - const selectedFilesTable = document.getElementById( - "selected-files-table", - ) - const selectedFilesBody = selectedFilesTable?.querySelector("tbody") - if (selectedFilesBody) { - this.renderSelectedFilesTable(selectedFilesBody) - } - } - - // Update count badge - const countBadge = document.querySelector(".selected-files-count") - if (countBadge) { - countBadge.textContent = `${this.selectedFiles.size} selected` - } - } - - /** - * Load file tree - */ - async loadFileTree() { - try { - const params = this.getFileSearchParams() - const extensionSelect = document.getElementById("file-extension") - const data = await this.fetchFiles(params) - if (!data.tree) { - console.error("No tree data received:", data) - return - } - - // Update file extension select options - if (extensionSelect && data.extension_choices) { - await window.DOMUtils.renderSelectOptions( - extensionSelect, - data.extension_choices, - ) - } - - // Pass the search parameters to renderFileTree - const searchTermEntered = - params.file_name || params.directory || params.file_extension - - this.renderFileTree(data.tree, null, 0, "", searchTermEntered) - - // Initialize search handler after tree is loaded - this.initializeEventListeners() - - // Initialize select all checkbox handler for the current file tree - this.initializeSelectAllCheckbox() - - // Initialize remove all button handler for the current file tree - this.initializeRemoveAllButton() - } catch (error) { - console.error("Error loading file tree:", error) - } - } - - /** - * Get relative path - * @param {Object} file - File object - * @param {string} currentPath - Current path - * @returns {string} Relative path - */ - getRelativePath(file, currentPath = "") { - if (!currentPath) { - return "" - } - return `/${currentPath}` - } - - /** - * Render file tree - * @param {Object} tree - File tree data - * @param {HTMLElement} parentElement - Parent element - * @param {number} level - Nesting level - * @param {string} currentPath - Current path - * @param {boolean} searchTermEntered - Whether search term was entered - */ - renderFileTree( - tree, - parentElement = null, - level = 0, - currentPath = "", - searchTermEntered = false, - ) { - this.currentTree = tree - const targetElement = - parentElement || document.querySelector("#file-tree-table tbody") - if (!targetElement) { - console.error("File tree table body not found") - return - } - - if (!parentElement) { - targetElement.innerHTML = "" - } - - // Early return if no tree or if tree is empty - if ( - !tree || - ((!tree.files || tree.files.length === 0) && - (!tree.children || Object.keys(tree.children).length === 0)) - ) { - targetElement.innerHTML = - 'No files or directories found' - return - } - - // Show/hide select all checkbox based on search term - const selectAllContainer = document.getElementById( - "select-all-container", - ) - const hasFiles = tree.files && tree.files.length > 0 - if (selectAllContainer) { - if (searchTermEntered && hasFiles) { - window.DOMUtils.show(selectAllContainer) - } else { - window.DOMUtils.hide(selectAllContainer) - } - } - - // Render directories - const directories = tree.children || {} - - for (const [name, content] of Object.entries(directories)) { - if ( - name === "files" || - !content || - typeof content !== "object" || - !content.type || - content.type !== "directory" - ) { - continue - } - - const row = document.createElement("tr") - row.className = "folder-row" - - // Set initial toggle state based on search term only (don't expand by default) - const initiallyExpanded = searchTermEntered - const toggleSymbol = initiallyExpanded ? "▼" : "▶" - - // Construct the path for this directory - const dirPath = currentPath - ? `${currentPath}/${content.name || name}` - : content.name || name - - row.innerHTML = ` - - ${toggleSymbol} - - - + `; + capturesContainer.appendChild(selectedPane); + } + + /** + * Update selected captures pane + */ + async updateSelectedCapturesPane() { + const selectedList = document.getElementById("selected-captures-list"); + const countBadge = document.querySelector(".selected-captures-count"); + if (!selectedList || !countBadge || !this.formHandler) return; + + const selectedCaptures = this.formHandler.selectedCaptures; + countBadge.textContent = `${selectedCaptures.size} selected`; + + // Prepare captures data for server-side rendering + const capturesData = Array.from(selectedCaptures).map((captureId) => { + const data = this.selectedCaptureDetails.get(captureId) || { + type: "Unknown", + directory: "Unknown", + }; + return { + id: captureId, + type: data.type, + directory: data.directory, + }; + }); + + await this.renderSelectedCapturesTable(selectedList, capturesData); + + // Add remove handlers after async render (DOM must contain buttons) + const removeSelectedButtons = selectedList.querySelectorAll( + ".remove-selected-capture", + ); + for (const button of removeSelectedButtons) { + button.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + const captureId = button.dataset.id; + + // Check if formHandler has a custom removal handler for edit mode + if (this.formHandler?.handleCaptureRemoval) { + this.formHandler.handleCaptureRemoval(captureId); + } else { + // Default behavior for create mode + this.formHandler.selectedCaptures.delete(captureId); + this.selectedCaptureDetails.delete(captureId); + + // Update checkbox if visible + const checkbox = document.querySelector( + `input[name="captures"][value="${captureId}"]`, + ); + if (checkbox) { + checkbox.checked = false; + checkbox.closest("tr").classList.remove("table-warning"); + } + + void this.updateSelectedCapturesPane(); + this.formHandler.updateHiddenFields(); + } + }); + } + } + + /** + * Render selected captures table asynchronously + */ + async renderSelectedCapturesTable(selectedList, capturesData) { + // Normalize for generic table_rows template + const rows = capturesData.map((capture) => ({ + data_attrs: { "capture-id": capture.id }, + cells: [ + { kind: "text", value: capture.type }, + { kind: "text", value: capture.directory }, + ], + actions: [ + { + label: "Remove", + icon: "bi-x", + css_class: "btn-danger", + extra_class: "remove-selected-capture", + data_attrs: { id: capture.id }, + }, + ], + })); + + // Use the generic table_rows template via DOMUtils + const success = await window.DOMUtils.renderTable(selectedList, rows, { + empty_message: "No captures selected", + empty_colspan: 3, + }); + + if (!success) { + console.error("Error rendering selected captures table"); + } + } + + /** + * Fetch captures data + * @param {Object} params - Search parameters + * @returns {Promise} Captures data + */ + async fetchCaptures(params = {}) { + try { + const searchParams = new URLSearchParams(); + + // Add all params to the search parameters + for (const [key, value] of Object.entries(params)) { + if (value) { + searchParams.append(key, value); + } + } + + // Always add the search_captures parameter + searchParams.append("search_captures", "true"); + + const data = await window.APIClient.request( + `${this.config.apiEndpoint}?${searchParams.toString()}`, + { + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + }, + ); + + // APIClient.request already returns parsed JSON data + return data; + } catch (error) { + console.error("Error fetching captures:", error); + return { results: [], pagination: {} }; + } + } + + /** + * Fetch files data + * @param {Object} params - Search parameters + * @returns {Promise} Files data + */ + async fetchFiles(params = {}) { + try { + const searchParams = new URLSearchParams(params); + const data = await window.APIClient.request( + `${this.config.apiEndpoint}?${searchParams.toString()}&search_files=true`, + { + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + }, + ); + + // APIClient.request already returns parsed JSON data + return data; + } catch (error) { + console.error("Error fetching files:", error); + return { tree: {}, pagination: {} }; + } + } + + /** + * Update captures table + * @param {Object} data - Captures data + */ + async updateCapturesTable(data) { + const tbody = document.querySelector("#captures-table tbody"); + + // Update the results count + this.updateResultsCount(data.results.length); + + // Transform captures data for table_rows.html template + const rows = data.results.map((capture) => { + const captureIdStr = capture.id.toString(); + const inExistingDataset = + this.isEditMode && + this.formHandler?.currentCaptures && + (this.formHandler.currentCaptures.has(capture.id) || + this.formHandler.currentCaptures.has(captureIdStr)); + const isSelected = + inExistingDataset || + this.formHandler?.selectedCaptures?.has(captureIdStr); + const isOwnedByCurrentUser = + capture.owner_id === this.formHandler?.currentUserId; + const canSelect = isOwnedByCurrentUser; + // Edit only: captures already in the dataset stay checked and locked + const checkboxDisabled = + !canSelect || (this.isEditMode && inExistingDataset); + const ownerName = capture.owner + ? capture.owner.name || capture.owner.email || "-" + : "-"; + const createdAt = new Date(capture.created_at).toLocaleDateString( + "en-US", + { + month: "2-digit", + day: "2-digit", + year: "numeric", + }, + ); + + return { + id: capture.id, + css_class: `capture-row${isSelected ? " table-warning" : ""}${checkboxDisabled ? " readonly-row" : ""}${inExistingDataset ? " capture-in-dataset" : ""}`, + data_attrs: { + "capture-id": capture.id, + }, + cells: [ + { + kind: "html", + tag: "input", + class: "form-check-input capture-checkbox", + name: "captures", + tag_attrs: { + type: "checkbox", + checked: isSelected, + disabled: checkboxDisabled, + value: capture.id, + }, + data_attrs: { + "capture-type": capture.type, + "capture-directory": capture.directory, + "capture-channel": capture.channel, + "capture-scan-group": capture.scan_group, + "capture-created-at": capture.created_at, + "capture-owner-id": capture.owner_id, + "capture-owner-name": ownerName, + }, + }, + { kind: "text", value: capture.type }, + { kind: "text", value: capture.directory }, + { kind: "text", value: capture.channel }, + { kind: "text", value: capture.scan_group }, + { kind: "text", value: ownerName }, + { kind: "text", value: createdAt }, + ], + }; + }); + + // Render using DOMUtils + const success = await window.DOMUtils.renderTable(tbody, rows, { + empty_message: "No captures found", + empty_colspan: 7, + }); + + if (success) { + // Attach event handlers to rendered rows + this.attachCaptureRowHandlers(tbody); + } else { + await window.DOMUtils.showMessage("Error loading captures", { + variant: "danger", + placement: "replace", + target: tbody, + presentation: "table", + templateContext: { colspan: 7 }, + }); + } + + // Update pagination with current filters + this.updatePagination("captures", data.pagination); + + // Update selected captures pane + await this.updateSelectedCapturesPane(); + } + + /** + * Attach event handlers to capture table rows + * @param {Element} tbody - Table body element + */ + attachCaptureRowHandlers(tbody) { + const rows = tbody.querySelectorAll("tr[data-capture-id]"); + + for (const row of rows) { + const checkbox = row.querySelector("input.capture-checkbox"); + if (!checkbox) continue; + + const captureId = checkbox.value; + const captureData = { + type: checkbox.dataset.captureType, + directory: checkbox.dataset.captureDirectory, + channel: checkbox.dataset.captureChannel, + scan_group: checkbox.dataset.captureScanGroup, + created_at: checkbox.dataset.captureCreatedAt, + owner_id: checkbox.dataset.captureOwnerId, + owner_name: checkbox.dataset.captureOwnerName, + }; + + const handleSelection = (e) => { + if (checkbox.disabled) { + e.preventDefault(); + e.stopPropagation(); + return; + } + + if (e.target.type !== "checkbox") { + checkbox.checked = !checkbox.checked; + } + + if (checkbox.checked) { + // Check if this is an editing handler + if (this.formHandler.addCaptureToPending) { + this.formHandler.addCaptureToPending(captureId, captureData); + } else { + // Regular selection for creation + this.formHandler.selectedCaptures.add(captureId); + row.classList.add("table-warning"); + this.selectedCaptureDetails.set(captureId, captureData); + this.formHandler.updateHiddenFields(); + void this.updateSelectedCapturesPane(); + } + + if (this.formHandler.updateCurrentCapturesList) { + this.formHandler.updateCurrentCapturesList(); + } + } else { + if (this.formHandler.addCaptureToPending) { + this.formHandler.cancelCaptureChange(captureId); + } else { + this.formHandler.selectedCaptures.delete(captureId); + row.classList.remove("table-warning"); + this.selectedCaptureDetails.delete(captureId); + this.formHandler.updateHiddenFields(); + void this.updateSelectedCapturesPane(); + } + + if (this.formHandler.updateCurrentCapturesList) { + this.formHandler.updateCurrentCapturesList(); + } + } + }; + + // Add click handler for the row + row.addEventListener("click", handleSelection); + + // Add specific handler for checkbox to prevent double-triggering + checkbox.addEventListener("change", (e) => { + e.stopPropagation(); + handleSelection(e); + }); + } + } + + /** + * Update pagination + * @param {string} type - Type of pagination (captures or files) + * @param {Object} pagination - Pagination data + */ + async updatePagination(type, pagination) { + const paginationContainer = document.querySelector(`#${type}-pagination`); + if (!paginationContainer) return; + + const success = await window.DOMUtils.renderPagination( + paginationContainer, + pagination, + ); + + if (success && pagination && pagination.num_pages > 1) { + // Attach click handlers after rendering + this.attachPaginationHandlers(type, paginationContainer); + } + } + + /** + * Attach pagination click handlers + * @param {string} type - Type of pagination (captures or files) + * @param {Element} container - Pagination container element + */ + attachPaginationHandlers(type, container) { + const links = container.querySelectorAll("a.page-link"); + for (const link of links) { + link.addEventListener("click", async (e) => { + e.preventDefault(); + const target = e.target.closest("a.page-link"); + const page = target?.dataset.page; + + if (type === "captures") { + const params = { + ...this.currentFilters, + page: page, + }; + const data = await this.fetchCaptures(params); + this.updateCapturesTable(data); + } else { + const data = await this.fetchFiles( + this.getFileSearchParams({ page }), + ); + this.updateFilesTable(data); + } + }); + } + } + + /** + * Handle search + */ + async handleSearch() { + try { + // Get all input elements within the search container + const searchContainer = this.searchForm; + if (!searchContainer) { + console.error("Search container not found:", this.searchForm); + return; + } + const params = new URLSearchParams(); + + // Get all form inputs within the container + const inputs = searchContainer.querySelectorAll( + "input, select, textarea", + ); + for (const input of inputs) { + if (input.value) { + params.append(input.name, input.value); + } + } + + // Add the search type parameter + params.append( + this.type === "captures" ? "search_captures" : "search_files", + "true", + ); + + const data = await window.APIClient.request( + `${this.config.apiEndpoint}?${params.toString()}`, + { + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + }, + ); + + // APIClient.request already returns parsed JSON data + + if (this.type === "captures") { + this.updateCapturesTable(data); + } else { + // Reset select all checkbox + const selectAllCheckbox = document.getElementById( + "select-all-files-checkbox", + ); + if (selectAllCheckbox) { + selectAllCheckbox.checked = false; + } + if (data.tree) { + // Update file extension select options while preserving current selection + const extensionSelect = document.getElementById("file-extension"); + if (extensionSelect && data.extension_choices) { + const currentValue = extensionSelect.value; + await window.DOMUtils.renderSelectOptions( + extensionSelect, + data.extension_choices, + currentValue, + ); + } + + // Restore search values if they exist + if (data.search_values) { + const fileNameInput = document.getElementById("file-name"); + const directoryInput = document.getElementById("file-directory"); + + if (fileNameInput) { + fileNameInput.value = data.search_values.file_name || ""; + } + if (extensionSelect) { + extensionSelect.value = data.search_values.file_extension || ""; + } + if (directoryInput) { + directoryInput.value = data.search_values.directory || ""; + } + } + + const searchTermEntered = + data.search_values.file_name || + data.search_values.directory || + data.search_values.file_extension; + + this.renderFileTree(data.tree, null, 0, "", searchTermEntered); + + // Initialize select all checkbox handler for the current file tree + this.initializeSelectAllCheckbox(); + + // Initialize remove all button handler for the current file tree + this.initializeRemoveAllButton(); + } + } + + if (data.pagination) { + this.updatePagination(this.type, data.pagination); + } + } catch (error) { + console.error("Error during search:", error); + this.showError("An error occurred during the search. Please try again."); + } + } + + /** + * Handle clear + */ + handleClear() { + // Clear all form inputs + const searchContainer = this.searchForm; + if (!searchContainer) { + console.error("Search container not found:", this.searchForm); + return; + } + + const inputs = searchContainer.querySelectorAll("input, select, textarea"); + for (const input of inputs) { + input.value = ""; + } + + // Trigger a new search with empty parameters + this.handleSearch(); + } + + /** + * Update selected files list + */ + updateSelectedFilesList() { + // Update form handler's selectedFiles with current selection (create mode only) + if (this.formHandler && !this.isEditMode) { + // Convert Map entries to array of file objects with IDs + const fileList = Array.from(this.selectedFiles.entries()).map( + ([id, file]) => ({ ...file, id: id }), + ); + this.formHandler.selectedFiles = new Set(fileList); + } + + // Update selected files display input + const selectedFilesDisplay = document.getElementById( + "selected-files-display", + ); + if (selectedFilesDisplay) { + selectedFilesDisplay.value = `${this.selectedFiles.size} file(s) selected`; + } + + // Update Remove All button state + const removeAllButton = document.getElementById( + "remove-all-selected-files-button", + ); + if (removeAllButton) { + removeAllButton.disabled = this.selectedFiles.size === 0; + } + + // Update selected files table if it exists (only in create mode) + if (!this.isEditMode) { + const selectedFilesTable = document.getElementById( + "selected-files-table", + ); + const selectedFilesBody = selectedFilesTable?.querySelector("tbody"); + if (selectedFilesBody) { + this.renderSelectedFilesTable(selectedFilesBody); + } + } + + // Update count badge + const countBadge = document.querySelector(".selected-files-count"); + if (countBadge) { + countBadge.textContent = `${this.selectedFiles.size} selected`; + } + } + + /** + * Load file tree + */ + async loadFileTree() { + try { + const params = this.getFileSearchParams(); + const extensionSelect = document.getElementById("file-extension"); + const data = await this.fetchFiles(params); + if (!data.tree) { + console.error("No tree data received:", data); + return; + } + + // Update file extension select options + if (extensionSelect && data.extension_choices) { + await window.DOMUtils.renderSelectOptions( + extensionSelect, + data.extension_choices, + ); + } + + // Pass the search parameters to renderFileTree + const searchTermEntered = + params.file_name || params.directory || params.file_extension; + + this.renderFileTree(data.tree, null, 0, "", searchTermEntered); + + // Initialize search handler after tree is loaded + this.initializeEventListeners(); + + // Initialize select all checkbox handler for the current file tree + this.initializeSelectAllCheckbox(); + + // Initialize remove all button handler for the current file tree + this.initializeRemoveAllButton(); + } catch (error) { + console.error("Error loading file tree:", error); + } + } + + /** + * Get relative path + * @param {Object} file - File object + * @param {string} currentPath - Current path + * @returns {string} Relative path + */ + getRelativePath(file, currentPath = "") { + if (!currentPath) { + return ""; + } + return `/${currentPath}`; + } + + /** + * Render file tree + * @param {Object} tree - File tree data + * @param {HTMLElement} parentElement - Parent element + * @param {number} level - Nesting level + * @param {string} currentPath - Current path + * @param {boolean} searchTermEntered - Whether search term was entered + */ + renderFileTree( + tree, + parentElement = null, + level = 0, + currentPath = "", + searchTermEntered = false, + ) { + this.currentTree = tree; + const targetElement = parentElement || this.getFileTreeRoot(); + if (!targetElement) { + console.error("File tree root not found"); + return; + } + + if (!parentElement) { + targetElement.innerHTML = ""; + } + + if ( + !tree || + ((!tree.files || tree.files.length === 0) && + (!tree.children || Object.keys(tree.children).length === 0)) + ) { + targetElement.innerHTML = + '
  • No files or directories found
  • '; + return; + } + + const selectAllContainer = document.getElementById("select-all-container"); + const hasFiles = tree.files && tree.files.length > 0; + if (selectAllContainer) { + if (searchTermEntered && hasFiles) { + window.DOMUtils.show(selectAllContainer); + } else { + window.DOMUtils.hide(selectAllContainer); + } + } + + const directories = tree.children || {}; + + for (const [name, content] of Object.entries(directories)) { + if ( + name === "files" || + !content || + typeof content !== "object" || + !content.type || + content.type !== "directory" + ) { + continue; + } + + const initiallyExpanded = searchTermEntered; + const folderIcon = initiallyExpanded + ? "bi-folder2-open" + : "bi-folder-fill"; + const dirPath = currentPath + ? `${currentPath}/${content.name || name}` + : content.name || name; + const hasChildDirs = + Object.keys(content.children || {}).filter( + (key) => + key !== "files" && + content.children[key]?.type === "directory", + ).length > 0; + const hasFilesInDir = content.files && content.files.length > 0; + const expandable = hasChildDirs || hasFilesInDir; + + const li = document.createElement("li"); + li.className = "folder-item"; + + const rowSpan = document.createElement("span"); + rowSpan.setAttribute("role", "button"); + rowSpan.setAttribute("tabindex", "0"); + rowSpan.setAttribute( + "aria-expanded", + initiallyExpanded ? "true" : "false", + ); + rowSpan.innerHTML = ` + + ${content.name || name} - - Directory - ${window.DOMUtils.formatFileSize(content.size || 0)} - ${content.created_at ? new Date(content.created_at).toLocaleDateString("en-US", { month: "2-digit", day: "2-digit", year: "numeric" }) : "-"} - ` - targetElement.appendChild(row) - - // Create container for nested content - const nestedContainer = document.createElement("tr") - nestedContainer.className = "nested-row" - if (!initiallyExpanded) { - window.DOMUtils.hide(nestedContainer) - } else { - window.DOMUtils.show(nestedContainer, "display-table-row") - } - nestedContainer.innerHTML = ` - -
    - - -
    -
    - - ` - targetElement.appendChild(nestedContainer) - - // Add click handler for folder - row.addEventListener("click", (e) => { - e.preventDefault() - e.stopPropagation() - const hasChildren = - Object.keys(content.children || {}).length > 0 - const hasFiles = content.files && content.files.length > 0 - const expandable = hasChildren || hasFiles - - const toggle = row.querySelector(".folder-toggle") - const isExpanded = toggle.textContent === "▼" - - if (expandable) { - toggle.textContent = isExpanded ? "▶" : "▼" - - if (isExpanded) { - window.DOMUtils.hide( - nestedContainer, - "display-table-row", - ) - } else { - window.DOMUtils.show( - nestedContainer, - "display-table-row", - ) - } - } else { - toggle.textContent = "▶" - window.DOMUtils.hide(nestedContainer, "display-table-row") - } - - // Load nested content if not already loaded - if ( - expandable && - !isExpanded && - !nestedContainer.dataset.loaded - ) { - this.renderFileTree( - content, - nestedContainer.querySelector("tbody"), - level + 1, - dirPath, - searchTermEntered, - ) - nestedContainer.dataset.loaded = "true" - } - }) - - // If there's a search term or initially expanded, automatically load and expand the content - if (initiallyExpanded && !nestedContainer.dataset.loaded) { - this.renderFileTree( - content, - nestedContainer.querySelector("tbody"), - level + 1, - dirPath, - searchTermEntered, - ) - nestedContainer.dataset.loaded = "true" - } - } - - // Render files - if (tree.files && tree.files.length > 0) { - for (const file of tree.files) { - const row = document.createElement("tr") - const filePath = this.getRelativePath(file, currentPath) - const isSelected = this.selectedFiles.has(file.id) - - // Check if file is already in the dataset (edit mode only) - const isExistingFile = - this.isEditMode && - this.formHandler?.currentFiles?.has(file.id) - row.innerHTML = ` - - + `; + + const childUl = document.createElement("ul"); + childUl.setAttribute("role", "group"); + + li.appendChild(rowSpan); + li.appendChild(childUl); + targetElement.appendChild(li); + + if (initiallyExpanded && expandable) { + this.renderFileTree( + content, + childUl, + level + 1, + dirPath, + searchTermEntered, + ); + childUl.dataset.loaded = "true"; + } + + rowSpan.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + if (!expandable) { + return; + } + + const isExpanded = rowSpan.getAttribute("aria-expanded") === "true"; + const newExpanded = !isExpanded; + rowSpan.setAttribute( + "aria-expanded", + newExpanded ? "true" : "false", + ); + + const icon = rowSpan.querySelector(".bi"); + if (icon) { + icon.classList.remove("bi-folder-fill", "bi-folder2-open"); + icon.classList.add( + newExpanded ? "bi-folder2-open" : "bi-folder-fill", + ); + } + + if (newExpanded && childUl.dataset.loaded !== "true") { + this.renderFileTree( + content, + childUl, + level + 1, + dirPath, + searchTermEntered, + ); + childUl.dataset.loaded = "true"; + } + }); + } + + if (tree.files && tree.files.length > 0) { + for (const file of tree.files) { + const filePath = this.getRelativePath(file, currentPath); + const isSelected = this.selectedFiles.has(file.id); + const isExistingFile = + this.isEditMode && this.formHandler?.currentFiles?.has(file.id); + + const li = document.createElement("li"); + li.className = "file-item"; + li.dataset.fileId = file.id; + + const rowSpan = document.createElement("span"); + rowSpan.setAttribute("tabindex", "0"); + rowSpan.innerHTML = ` + + - - - + ${file.name} - - ${file.media_type || "Unknown"} - ${window.DOMUtils.formatFileSize(file.size)} - ${new Date(file.created_at).toLocaleDateString("en-US", { month: "2-digit", day: "2-digit", year: "numeric" })} - ` - - const checkbox = row.querySelector('input[type="checkbox"]') - - // Only add event handlers if checkbox is not disabled (not an existing file) - if (!isExistingFile) { - // Add click handler for the checkbox - checkbox.addEventListener("change", (e) => { - e.stopPropagation() // Prevent row click from firing - if (checkbox.checked) { - // Add to intermediate selection (both edit and create mode) - this.selectedFiles.set(file.id, { - ...file, - relative_path: filePath, - }) - } else { - // Remove from intermediate selection (both edit and create mode) - this.selectedFiles.delete(file.id) - } - this.updateSelectAllCheckboxState() - this.updateSelectedFilesList() - }) - - // Add click handler for the row - row.addEventListener("click", (e) => { - // Don't toggle if clicking the checkbox directly - if (e.target.type === "checkbox") return - - checkbox.checked = !checkbox.checked - // Trigger the change event to ensure the selectedFiles is updated - checkbox.dispatchEvent(new Event("change")) - }) - - // Add hover effect class - row.classList.add("clickable-row") - } else { - // For existing files, add a visual indicator - row.classList.add("readonly-row") - row.title = "This file is already in the dataset" - } - - targetElement.appendChild(row) - } - } - - // Update select all checkbox state when rendering new tree - this.updateSelectAllCheckboxState() - } - - /** - * Update files table - * @param {Object} data - Files data - */ - updateFilesTable(data) { - const tbody = document.querySelector("#file-tree-table tbody") - if (!tbody) { - console.error("File tree table body not found") - return - } - tbody.innerHTML = "" - - if (!data.tree) { - this.renderEmptyFilesTable(tbody) - return - } + + `; + + li.appendChild(rowSpan); + const checkbox = rowSpan.querySelector('input[type="checkbox"]'); + + if (!isExistingFile) { + checkbox.addEventListener("change", (e) => { + e.stopPropagation(); + if (checkbox.checked) { + this.selectedFiles.set(file.id, { + ...file, + relative_path: filePath, + }); + } else { + this.selectedFiles.delete(file.id); + } + this.updateSelectAllCheckboxState(); + this.updateSelectedFilesList(); + }); + + rowSpan.addEventListener("click", (e) => { + if (e.target.type === "checkbox") { + return; + } + checkbox.checked = !checkbox.checked; + checkbox.dispatchEvent(new Event("change")); + }); + + li.classList.add("clickable-row"); + } else { + li.classList.add("readonly-row"); + li.title = "This file is already in the dataset"; + } + + targetElement.appendChild(li); + } + } + + this.updateSelectAllCheckboxState(); + } + + /** + * Update files table + * @param {Object} data - Files data + */ + updateFilesTable(data) { + const root = this.getFileTreeRoot(); + if (!root) { + console.error("File tree root not found"); + return; + } + root.innerHTML = ""; + + if (!data.tree) { + this.renderEmptyFileTree(root); + return; + } this.renderFileTree(data.tree) } - /** - * Render empty files table asynchronously - */ - async renderEmptyFilesTable(tbody) { - try { - const response = await window.APIClient.post( - "/users/render-html/", - { - template: "users/components/empty_table_row.html", - context: { - colspan: 5, - message: "No files or directories found", - }, - }, - null, - true, - ) // true = send as JSON - - if (response.html) { - tbody.innerHTML = response.html - } - } catch (error) { - console.error("Error rendering empty files table:", error) - // Fallback - tbody.innerHTML = - 'No files or directories found' - } - } + /** + * Render empty file tree placeholder + * @param {HTMLElement} root - File tree root element + */ + renderEmptyFileTree(root) { + root.innerHTML = + '
  • No files or directories found
  • '; + } /** * Show error message @@ -1561,20 +1502,15 @@ class AssetSearchHandler { ) if (!selectAllCheckbox) return - // Only count visible file checkboxes (not in hidden rows) - const fileCheckboxes = document.querySelectorAll( - '#file-tree-table tbody tr:not(.nested-row):not([style*="display: none"]) input[type="checkbox"]', - ) - const checkedBoxes = document.querySelectorAll( - '#file-tree-table tbody tr:not(.nested-row):not([style*="display: none"]) input[type="checkbox"]:checked', - ) + const fileCheckboxes = this.getVisibleFileCheckboxes(); + const checkedBoxes = fileCheckboxes.filter((checkbox) => checkbox.checked); - if (checkedBoxes.length === fileCheckboxes.length) { - selectAllCheckbox.checked = true - } else { - selectAllCheckbox.checked = false - } - } + if (checkedBoxes.length === fileCheckboxes.length && fileCheckboxes.length > 0) { + selectAllCheckbox.checked = true; + } else { + selectAllCheckbox.checked = false; + } + } /** * Update results count diff --git a/gateway/sds_gateway/static/js/search/__tests__/AssetSearchHandler.test.js b/gateway/sds_gateway/static/js/search/__tests__/AssetSearchHandler.test.js index 4c21af510..1d2e1eb42 100644 --- a/gateway/sds_gateway/static/js/search/__tests__/AssetSearchHandler.test.js +++ b/gateway/sds_gateway/static/js/search/__tests__/AssetSearchHandler.test.js @@ -16,438 +16,432 @@ const { } = require("../../tests-config/testHelpers.js") describe("AssetSearchHandler", () => { - let searchHandler - let mockConfig - let mockForm - let mockButton - let mockTableBody - - beforeEach(() => { - mockForm = createMockFormElement() - mockButton = createMockButtonElement() - mockTableBody = { - innerHTML: "", - querySelectorAll: jest.fn(() => []), - } - - const defaultSearchResponse = { - success: true, - results: [ - { id: 1, name: "Test Capture 1", type: "rh" }, - { id: 2, name: "Test Capture 2", type: "drf" }, - ], - pagination: { - current_page: 1, - total_pages: 1, - has_next: false, - has_previous: false, - }, - } - - setupStandardUnitTest({ - getElementByIdMap: createAssetSearchGetElementByIdMap({ - mockForm, - mockButton, - mockTableBody, - }), - apiClientOverrides: { - request: jest.fn().mockResolvedValue(defaultSearchResponse), - }, - }) - - mockConfig = createDefaultAssetSearchConfig() - - document.querySelector = jest.fn((selector) => { - if (selector === "#captures-table tbody") { - return mockTableBody - } - return null - }) - - mergeWindowMocks({ - datasetEditingHandler: { - addFileToPending: jest.fn(), - }, - }) - }) - - describe("Initialization", () => { - test("should initialize with correct configuration", () => { - searchHandler = new AssetSearchHandler(mockConfig) - - expect(searchHandler.searchForm).toBe(mockForm) - expect(searchHandler.searchButton).toBe(mockButton) - expect(searchHandler.clearButton).toBe(mockButton) - expect(searchHandler.tableBody).toBe(mockTableBody) - expect(searchHandler.type).toBe("captures") - expect(searchHandler.isEditMode).toBe(false) - expect(searchHandler.selectedFiles).toBeInstanceOf(Map) - expect(searchHandler.selectedCaptureDetails).toBeInstanceOf(Map) - }) - - test("should setup form handler reference", () => { - searchHandler = new AssetSearchHandler(mockConfig) - - expect( - mockConfig.formHandler.setSearchHandler, - ).toHaveBeenCalledWith(searchHandler, "captures") - }) - - test("should initialize with initial data", () => { - const configWithInitialData = { - ...mockConfig, - initialFileDetails: { "file1-uuid": { name: "test.h5" } }, - initialCaptureDetails: { - "capture1-uuid": { name: "test capture" }, - }, - } - - searchHandler = new AssetSearchHandler(configWithInitialData) - - expect(searchHandler.selectedFiles.has("file1-uuid")).toBe(true) - expect( - searchHandler.selectedCaptureDetails.has("capture1-uuid"), - ).toBe(true) - }) - }) - - describe("Event Listeners", () => { - beforeEach(() => { - searchHandler = new AssetSearchHandler(mockConfig) - }) - - test.each([ - ["search button"], - ["clear button"], - ["confirm file selection"], - ])("should setup %s event listener", (buttonName) => { - expect(mockButton.addEventListener).toHaveBeenCalledWith( - "click", - expect.any(Function), - ) - }) - - test("should setup enter key listener on search inputs", () => { - const mockInput = { - addEventListener: jest.fn(), - } - mockForm.querySelectorAll.mockReturnValue([mockInput]) - - searchHandler.initializeEnterKeyListener() - - expect(mockInput.addEventListener).toHaveBeenCalledWith( - "keypress", - expect.any(Function), - ) - }) - }) - - describe("Clear Functionality", () => { - beforeEach(() => { - searchHandler = new AssetSearchHandler(mockConfig) - }) - - test("should clear search results", () => { - searchHandler.handleClear() - - expect(mockTableBody.innerHTML).toBe("") - }) - - test("should clear search form", () => { - const mockInput = { value: "test search" } - mockForm.querySelectorAll.mockReturnValue([mockInput]) - - searchHandler.handleClear() - - expect(mockInput.value).toBe("") - }) - }) - - describe("Selection Properties", () => { - beforeEach(() => { - searchHandler = new AssetSearchHandler(mockConfig) - }) - - test.each([ - ["selectedFiles", Map], - ["selectedCaptureDetails", Map], - ])("should have %s property", (propertyName, expectedType) => { - expect(searchHandler[propertyName]).toBeDefined() - expect(searchHandler[propertyName]).toBeInstanceOf(expectedType) - }) - - test("should update selected files list", () => { - searchHandler.updateSelectedFilesList() - expect(searchHandler.updateSelectedFilesList).toBeDefined() - }) - }) - - describe("Select All Functionality", () => { - beforeEach(() => { - searchHandler = new AssetSearchHandler(mockConfig) - }) - - test("should initialize select all checkbox", () => { - const mockCheckbox = { - addEventListener: jest.fn(), - checked: false, - } - document.getElementById.mockImplementation((id) => { - if (id === "select-all-files-checkbox") return mockCheckbox - return null - }) - - searchHandler.initializeSelectAllCheckbox() - - expect(mockCheckbox.addEventListener).toHaveBeenCalledWith( - "change", - expect.any(Function), - ) - }) - }) - - describe("Edit Mode Integration", () => { - beforeEach(() => { - const editConfig = { - ...mockConfig, - isEditMode: true, - } - searchHandler = new AssetSearchHandler(editConfig) - }) - - test("should initialize in edit mode", () => { - expect(searchHandler.isEditMode).toBe(true) - }) - }) - - describe("Error Handling", () => { - beforeEach(() => { - searchHandler = new AssetSearchHandler(mockConfig) - }) - - test("should handle missing form handler gracefully", () => { - const configWithoutFormHandler = { - ...mockConfig, - formHandler: null, - } - - expect(() => { - new AssetSearchHandler(configWithoutFormHandler) - }).not.toThrow() - }) - }) - - describe("State Management", () => { - beforeEach(() => { - searchHandler = new AssetSearchHandler(mockConfig) - }) - - test("should track current filters", () => { - searchHandler.currentFilters = { type: "spectrum" } - - expect(searchHandler.currentFilters.type).toBe("spectrum") - }) - }) - - describe("Checkbox Disabling for Existing Files", () => { - let editModeHandler - let createModeHandler - let mockTargetElement - - beforeEach(() => { - // Setup edit mode handler with existing files - const editConfig = { - ...mockConfig, - isEditMode: true, - formHandler: { - setSearchHandler: jest.fn(), - currentFiles: new Map([ - [ - "existing-file-1", - { id: "existing-file-1", name: "existing.h5" }, - ], - ]), - }, - } - editModeHandler = new AssetSearchHandler(editConfig) - - // Setup create mode handler - const createConfig = { - ...mockConfig, - isEditMode: false, - } - createModeHandler = new AssetSearchHandler(createConfig) - - // Mock target element for rendering - mockTargetElement = document.createElement("tbody") - mockTargetElement.id = "file-tree-table-body" - - // Mock DOMUtils.formatFileSize - global.window.DOMUtils.formatFileSize = jest.fn( - (size) => size || "0 B", - ) - }) - - test("should disable checkbox and add readonly styling for existing files in edit mode", () => { - const existingFile = { - id: "existing-file-1", - name: "existing.h5", - media_type: "application/hdf5", - size: 1024, - created_at: "2024-01-01T00:00:00Z", - } - - const tree = { - files: [existingFile], - } - - editModeHandler.renderFileTree(tree, mockTargetElement) - - const row = mockTargetElement.querySelector("tr") - const checkbox = row.querySelector('input[type="checkbox"]') - - // Check that checkbox is disabled - expect(checkbox.disabled).toBe(true) - // Check that readonly-row class is added - expect(row.classList.contains("readonly-row")).toBe(true) - // Check that tooltip is set - expect(row.title).toBe("This file is already in the dataset") - // Check that clickable-row class is NOT added - expect(row.classList.contains("clickable-row")).toBe(false) - }) - - test("should enable checkbox and add event handlers for new files in edit mode", () => { - const newFile = { - id: "new-file-1", - name: "new.h5", - media_type: "application/hdf5", - size: 2048, - created_at: "2024-01-02T00:00:00Z", - } - - const tree = { - files: [newFile], - } - - // Spy on addEventListener to verify event handlers are attached - const addEventListenerSpy = jest.spyOn( - HTMLElement.prototype, - "addEventListener", - ) - - editModeHandler.renderFileTree(tree, mockTargetElement) - - const row = mockTargetElement.querySelector("tr") - const checkbox = row.querySelector('input[type="checkbox"]') - - // Check that checkbox is NOT disabled - expect(checkbox.disabled).toBe(false) - // Check that clickable-row class is added - expect(row.classList.contains("clickable-row")).toBe(true) - // Check that readonly-row class is NOT added - expect(row.classList.contains("readonly-row")).toBe(false) - // Verify event handlers were attached (change event for checkbox, click for row) - expect(addEventListenerSpy).toHaveBeenCalledWith( - "change", - expect.any(Function), - ) - expect(addEventListenerSpy).toHaveBeenCalledWith( - "click", - expect.any(Function), - ) - - addEventListenerSpy.mockRestore() - }) - - test("should enable all checkboxes in create mode regardless of file existence", () => { - const file = { - id: "any-file-1", - name: "any.h5", - media_type: "application/hdf5", - size: 1024, - created_at: "2024-01-01T00:00:00Z", - } - - const tree = { - files: [file], - } - - createModeHandler.renderFileTree(tree, mockTargetElement) - - const row = mockTargetElement.querySelector("tr") - const checkbox = row.querySelector('input[type="checkbox"]') - - // In create mode, all checkboxes should be enabled - expect(checkbox.disabled).toBe(false) - expect(row.classList.contains("readonly-row")).toBe(false) - expect(row.classList.contains("clickable-row")).toBe(true) - }) - - test("should handle missing formHandler gracefully when rendering", () => { - const configWithoutFormHandler = { - ...mockConfig, - isEditMode: true, - formHandler: null, - } - const handler = new AssetSearchHandler(configWithoutFormHandler) - - const file = { - id: "any-file-1", - name: "any.h5", - media_type: "application/hdf5", - size: 1024, - created_at: "2024-01-01T00:00:00Z", - } - - const tree = { - files: [file], - } - - // Should not throw when formHandler is null - expect(() => { - handler.renderFileTree(tree, mockTargetElement) - }).not.toThrow() - - // File should be enabled since formHandler is null - const checkbox = mockTargetElement.querySelector( - 'input[type="checkbox"]', - ) - expect(checkbox.disabled).toBe(false) - }) - - test("should handle missing currentFiles gracefully when rendering", () => { - const configWithoutCurrentFiles = { - ...mockConfig, - isEditMode: true, - formHandler: { - setSearchHandler: jest.fn(), - currentFiles: null, - }, - } - const handler = new AssetSearchHandler(configWithoutCurrentFiles) - - const file = { - id: "any-file-1", - name: "any.h5", - media_type: "application/hdf5", - size: 1024, - created_at: "2024-01-01T00:00:00Z", - } - - const tree = { - files: [file], - } - - // Should not throw when currentFiles is null - expect(() => { - handler.renderFileTree(tree, mockTargetElement) - }).not.toThrow() - - // File should be enabled since currentFiles is null - const checkbox = mockTargetElement.querySelector( - 'input[type="checkbox"]', - ) - expect(checkbox.disabled).toBe(false) - }) - }) -}) + let searchHandler; + let mockConfig; + let mockForm; + let mockButton; + let mockTableBody; + + beforeEach(() => { + mockForm = createMockFormElement(); + mockButton = createMockButtonElement(); + mockTableBody = { + innerHTML: "", + querySelectorAll: jest.fn(() => []), + }; + + const defaultSearchResponse = { + success: true, + results: [ + { id: 1, name: "Test Capture 1", type: "rh" }, + { id: 2, name: "Test Capture 2", type: "drf" }, + ], + pagination: { + current_page: 1, + total_pages: 1, + has_next: false, + has_previous: false, + }, + }; + + setupStandardUnitTest({ + getElementByIdMap: createAssetSearchGetElementByIdMap({ + mockForm, + mockButton, + mockTableBody, + }), + apiClientOverrides: { + request: jest.fn().mockResolvedValue(defaultSearchResponse), + }, + }); + + mockConfig = createDefaultAssetSearchConfig(); + + document.querySelector = jest.fn((selector) => { + if (selector === "#captures-table tbody") { + return mockTableBody; + } + return null; + }); + + mergeWindowMocks({ + datasetEditingHandler: { + addFileToPending: jest.fn(), + }, + }); + }); + + describe("Initialization", () => { + test("should initialize with correct configuration", () => { + searchHandler = new AssetSearchHandler(mockConfig); + + expect(searchHandler.searchForm).toBe(mockForm); + expect(searchHandler.searchButton).toBe(mockButton); + expect(searchHandler.clearButton).toBe(mockButton); + expect(searchHandler.tableBody).toBe(mockTableBody); + expect(searchHandler.type).toBe("captures"); + expect(searchHandler.isEditMode).toBe(false); + expect(searchHandler.selectedFiles).toBeInstanceOf(Map); + expect(searchHandler.selectedCaptureDetails).toBeInstanceOf(Map); + }); + + test("should setup form handler reference", () => { + searchHandler = new AssetSearchHandler(mockConfig); + + expect(mockConfig.formHandler.setSearchHandler).toHaveBeenCalledWith( + searchHandler, + "captures", + ); + }); + + test("should initialize with initial data", () => { + const configWithInitialData = { + ...mockConfig, + initialFileDetails: { "file1-uuid": { name: "test.h5" } }, + initialCaptureDetails: { "capture1-uuid": { name: "test capture" } }, + }; + + searchHandler = new AssetSearchHandler(configWithInitialData); + + expect(searchHandler.selectedFiles.has("file1-uuid")).toBe(true); + expect(searchHandler.selectedCaptureDetails.has("capture1-uuid")).toBe( + true, + ); + }); + }); + + describe("Event Listeners", () => { + beforeEach(() => { + searchHandler = new AssetSearchHandler(mockConfig); + }); + + test.each([ + ["search button"], + ["clear button"], + ["confirm file selection"], + ])("should setup %s event listener", (buttonName) => { + expect(mockButton.addEventListener).toHaveBeenCalledWith( + "click", + expect.any(Function), + ); + }); + + test("should setup enter key listener on search inputs", () => { + const mockInput = { + addEventListener: jest.fn(), + }; + mockForm.querySelectorAll.mockReturnValue([mockInput]); + + searchHandler.initializeEnterKeyListener(); + + expect(mockInput.addEventListener).toHaveBeenCalledWith( + "keypress", + expect.any(Function), + ); + }); + }); + + describe("Clear Functionality", () => { + beforeEach(() => { + searchHandler = new AssetSearchHandler(mockConfig); + }); + + test("should clear search results", () => { + searchHandler.handleClear(); + + expect(mockTableBody.innerHTML).toBe(""); + }); + + test("should clear search form", () => { + const mockInput = { value: "test search" }; + mockForm.querySelectorAll.mockReturnValue([mockInput]); + + searchHandler.handleClear(); + + expect(mockInput.value).toBe(""); + }); + }); + + describe("Selection Properties", () => { + beforeEach(() => { + searchHandler = new AssetSearchHandler(mockConfig); + }); + + test.each([ + ["selectedFiles", Map], + ["selectedCaptureDetails", Map], + ])("should have %s property", (propertyName, expectedType) => { + expect(searchHandler[propertyName]).toBeDefined(); + expect(searchHandler[propertyName]).toBeInstanceOf(expectedType); + }); + + test("should update selected files list", () => { + searchHandler.updateSelectedFilesList(); + expect(searchHandler.updateSelectedFilesList).toBeDefined(); + }); + }); + + describe("Select All Functionality", () => { + beforeEach(() => { + searchHandler = new AssetSearchHandler(mockConfig); + }); + + test("should initialize select all checkbox", () => { + const mockCheckbox = { + addEventListener: jest.fn(), + checked: false, + }; + document.getElementById.mockImplementation((id) => { + if (id === "select-all-files-checkbox") return mockCheckbox; + return null; + }); + + searchHandler.initializeSelectAllCheckbox(); + + expect(mockCheckbox.addEventListener).toHaveBeenCalledWith( + "change", + expect.any(Function), + ); + }); + }); + + describe("Edit Mode Integration", () => { + beforeEach(() => { + const editConfig = { + ...mockConfig, + isEditMode: true, + }; + searchHandler = new AssetSearchHandler(editConfig); + }); + + test("should initialize in edit mode", () => { + expect(searchHandler.isEditMode).toBe(true); + }); + }); + + describe("Error Handling", () => { + beforeEach(() => { + searchHandler = new AssetSearchHandler(mockConfig); + }); + + test("should handle missing form handler gracefully", () => { + const configWithoutFormHandler = { + ...mockConfig, + formHandler: null, + }; + + expect(() => { + new AssetSearchHandler(configWithoutFormHandler); + }).not.toThrow(); + }); + }); + + describe("State Management", () => { + beforeEach(() => { + searchHandler = new AssetSearchHandler(mockConfig); + }); + + test("should track current filters", () => { + searchHandler.currentFilters = { type: "spectrum" }; + + expect(searchHandler.currentFilters.type).toBe("spectrum"); + }); + }); + + describe("Checkbox Disabling for Existing Files", () => { + let editModeHandler; + let createModeHandler; + let mockTargetElement; + + beforeEach(() => { + // Setup edit mode handler with existing files + const editConfig = { + ...mockConfig, + isEditMode: true, + formHandler: { + setSearchHandler: jest.fn(), + currentFiles: new Map([ + ["existing-file-1", { id: "existing-file-1", name: "existing.h5" }], + ]), + }, + }; + editModeHandler = new AssetSearchHandler(editConfig); + + // Setup create mode handler + const createConfig = { + ...mockConfig, + isEditMode: false, + }; + createModeHandler = new AssetSearchHandler(createConfig); + + // Mock target element for rendering + mockTargetElement = document.createElement("ul"); + mockTargetElement.id = "file-tree-root"; + + // Mock DOMUtils.formatFileSize + global.window.DOMUtils.formatFileSize = jest.fn((size) => size || "0 B"); + }); + + test("should disable checkbox and add readonly styling for existing files in edit mode", () => { + const existingFile = { + id: "existing-file-1", + name: "existing.h5", + media_type: "application/hdf5", + size: 1024, + created_at: "2024-01-01T00:00:00Z", + }; + + const tree = { + files: [existingFile], + }; + + editModeHandler.renderFileTree(tree, mockTargetElement); + + const row = mockTargetElement.querySelector("li.file-item"); + const checkbox = row.querySelector('input[type="checkbox"]'); + + // Check that checkbox is disabled + expect(checkbox.disabled).toBe(true); + // Check that readonly-row class is added + expect(row.classList.contains("readonly-row")).toBe(true); + // Check that tooltip is set + expect(row.title).toBe("This file is already in the dataset"); + // Check that clickable-row class is NOT added + expect(row.classList.contains("clickable-row")).toBe(false); + }); + + test("should enable checkbox and add event handlers for new files in edit mode", () => { + const newFile = { + id: "new-file-1", + name: "new.h5", + media_type: "application/hdf5", + size: 2048, + created_at: "2024-01-02T00:00:00Z", + }; + + const tree = { + files: [newFile], + }; + + // Spy on addEventListener to verify event handlers are attached + const addEventListenerSpy = jest.spyOn( + HTMLElement.prototype, + "addEventListener", + ); + + editModeHandler.renderFileTree(tree, mockTargetElement); + + const row = mockTargetElement.querySelector("li.file-item"); + const checkbox = row.querySelector('input[type="checkbox"]'); + + // Check that checkbox is NOT disabled + expect(checkbox.disabled).toBe(false); + // Check that clickable-row class is added + expect(row.classList.contains("clickable-row")).toBe(true); + // Check that readonly-row class is NOT added + expect(row.classList.contains("readonly-row")).toBe(false); + // Verify event handlers were attached (change event for checkbox, click for row) + expect(addEventListenerSpy).toHaveBeenCalledWith( + "change", + expect.any(Function), + ); + expect(addEventListenerSpy).toHaveBeenCalledWith( + "click", + expect.any(Function), + ); + + addEventListenerSpy.mockRestore(); + }); + + test("should enable all checkboxes in create mode regardless of file existence", () => { + const file = { + id: "any-file-1", + name: "any.h5", + media_type: "application/hdf5", + size: 1024, + created_at: "2024-01-01T00:00:00Z", + }; + + const tree = { + files: [file], + }; + + createModeHandler.renderFileTree(tree, mockTargetElement); + + const row = mockTargetElement.querySelector("li.file-item"); + const checkbox = row.querySelector('input[type="checkbox"]'); + + // In create mode, all checkboxes should be enabled + expect(checkbox.disabled).toBe(false); + expect(row.classList.contains("readonly-row")).toBe(false); + expect(row.classList.contains("clickable-row")).toBe(true); + }); + + test("should handle missing formHandler gracefully when rendering", () => { + const configWithoutFormHandler = { + ...mockConfig, + isEditMode: true, + formHandler: null, + }; + const handler = new AssetSearchHandler(configWithoutFormHandler); + + const file = { + id: "any-file-1", + name: "any.h5", + media_type: "application/hdf5", + size: 1024, + created_at: "2024-01-01T00:00:00Z", + }; + + const tree = { + files: [file], + }; + + // Should not throw when formHandler is null + expect(() => { + handler.renderFileTree(tree, mockTargetElement); + }).not.toThrow(); + + // File should be enabled since formHandler is null + const checkbox = mockTargetElement.querySelector( + 'input[type="checkbox"]', + ); + expect(checkbox.disabled).toBe(false); + }); + + test("should handle missing currentFiles gracefully when rendering", () => { + const configWithoutCurrentFiles = { + ...mockConfig, + isEditMode: true, + formHandler: { + setSearchHandler: jest.fn(), + currentFiles: null, + }, + }; + const handler = new AssetSearchHandler(configWithoutCurrentFiles); + + const file = { + id: "any-file-1", + name: "any.h5", + media_type: "application/hdf5", + size: 1024, + created_at: "2024-01-01T00:00:00Z", + }; + + const tree = { + files: [file], + }; + + // Should not throw when currentFiles is null + expect(() => { + handler.renderFileTree(tree, mockTargetElement); + }).not.toThrow(); + + // File should be enabled since currentFiles is null + const checkbox = mockTargetElement.querySelector( + 'input[type="checkbox"]', + ); + expect(checkbox.disabled).toBe(false); + }); + }); +}); diff --git a/gateway/sds_gateway/templates/users/partials/file_browser.html b/gateway/sds_gateway/templates/users/partials/file_browser.html index 556484c36..b221abf72 100644 --- a/gateway/sds_gateway/templates/users/partials/file_browser.html +++ b/gateway/sds_gateway/templates/users/partials/file_browser.html @@ -102,27 +102,21 @@ id="select-all-files-checkbox" /> - -
    - - - - - - - - - - - - - - - -
    NameTypeSizeCreated At
    - - Use the search form above to browse files -
    + +
    +
    +
    + Browse and select files +
    +
      +
    • + + Use the search form above to browse files +
    • +
    +
    From 1826e3edfca5605a671c3a32bae513be19c836fd Mon Sep 17 00:00:00 2001 From: klpoland Date: Thu, 4 Jun 2026 09:04:34 -0400 Subject: [PATCH 02/11] fix file browser bug on enter and reformat browser selection ui --- .../static/css/spectrumx_theme.css | 72 +++++++++++++++++++ .../js/dataset/DatasetEditingHandler.js | 13 ++++ .../static/js/search/AssetSearchHandler.js | 63 +++++++++++++--- 3 files changed, 140 insertions(+), 8 deletions(-) diff --git a/gateway/sds_gateway/static/css/spectrumx_theme.css b/gateway/sds_gateway/static/css/spectrumx_theme.css index 32485f196..25088cd90 100644 --- a/gateway/sds_gateway/static/css/spectrumx_theme.css +++ b/gateway/sds_gateway/static/css/spectrumx_theme.css @@ -272,6 +272,78 @@ textarea:focus-visible { width: 100%; } +/* Modal file picker: compact rows, hidden checkboxes, click-to-select */ +.file-browser-modal #file-tree-root { + padding-left: 0; +} + +.file-browser-modal .file-browser-row { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.5rem; + padding: 0.375rem 0.75rem; + border-radius: 6px; + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; + width: 100%; + box-sizing: border-box; +} + +.file-browser-modal .file-item > .file-browser-row:hover { + background-color: rgba(0, 90, 156, 0.08); +} + +.file-browser-modal .file-item.is-selected > .file-browser-row { + background-color: rgba(0, 90, 156, 0.16); + color: #003d6b; + font-weight: 500; +} + +.file-browser-modal .file-item.is-selected > .file-browser-row .bi { + color: #005a9c; +} + +.file-browser-modal .folder-item > .file-browser-row:hover { + background-color: #e9ecef; +} + +.file-browser-modal .file-item.readonly-row > .file-browser-row { + cursor: not-allowed; + opacity: 0.65; +} + +.file-browser-modal .file-item.readonly-row > .file-browser-row:hover { + background-color: transparent; +} + +.file-browser-modal .item-content { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; + min-width: 0; + justify-content: flex-start; +} + +.file-browser-modal .file-browser-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-browser-modal .file-checkbox { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .file-browser-header { display: flex; justify-content: space-between; diff --git a/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js b/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js index 4959fa93e..1428e83e7 100644 --- a/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js +++ b/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js @@ -84,6 +84,19 @@ class DatasetEditingHandler extends BaseManager { // Populate initial data now that handlers are ready this.populateFromInitialData(this.initialCaptures, this.initialFiles); } + + const datasetForm = document.getElementById("datasetForm"); + if (datasetForm && !datasetForm.dataset.enterSubmitGuardBound) { + datasetForm.dataset.enterSubmitGuardBound = "true"; + datasetForm.addEventListener("submit", (e) => { + e.preventDefault(); + }); + datasetForm.addEventListener("keypress", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + } + }); + } } /** diff --git a/gateway/sds_gateway/static/js/search/AssetSearchHandler.js b/gateway/sds_gateway/static/js/search/AssetSearchHandler.js index b5ed959d6..84450fef3 100644 --- a/gateway/sds_gateway/static/js/search/AssetSearchHandler.js +++ b/gateway/sds_gateway/static/js/search/AssetSearchHandler.js @@ -310,6 +310,8 @@ class AssetSearchHandler { config.formHandler.setSearchHandler(this, config.type) } + this._coreSearchListenersBound = false + this.initializeEventListeners() } @@ -332,6 +334,11 @@ class AssetSearchHandler { * Initialize event listeners */ initializeEventListeners() { + if (this._coreSearchListenersBound) { + return + } + this._coreSearchListenersBound = true + // Search form handlers if (this.searchButton) { this.searchButton.addEventListener("click", () => @@ -379,6 +386,8 @@ class AssetSearchHandler { for (const input of searchInputs) { input.addEventListener("keypress", (e) => { if (e.key === "Enter") { + e.preventDefault() + e.stopPropagation() this.handleSearch() } }) @@ -393,6 +402,10 @@ class AssetSearchHandler { "select-all-files-checkbox", ) if (!selectAllCheckbox) return + if (selectAllCheckbox.dataset?.selectAllBound === "true") return + if (selectAllCheckbox.dataset) { + selectAllCheckbox.dataset.selectAllBound = "true" + } selectAllCheckbox.addEventListener("change", () => { const isChecked = selectAllCheckbox.checked; @@ -415,6 +428,10 @@ class AssetSearchHandler { "remove-all-selected-files-button", ) if (!removeAllButton) return + if (removeAllButton.dataset?.removeAllBound === "true") return + if (removeAllButton.dataset) { + removeAllButton.dataset.removeAllBound = "true" + } removeAllButton.addEventListener("click", () => { // Check if formHandler has a custom removal handler for edit mode @@ -1103,9 +1120,6 @@ class AssetSearchHandler { this.renderFileTree(data.tree, null, 0, "", searchTermEntered); - // Initialize search handler after tree is loaded - this.initializeEventListeners(); - // Initialize select all checkbox handler for the current file tree this.initializeSelectAllCheckbox(); @@ -1208,6 +1222,7 @@ class AssetSearchHandler { li.className = "folder-item"; const rowSpan = document.createElement("span"); + rowSpan.className = "file-browser-row"; rowSpan.setAttribute("role", "button"); rowSpan.setAttribute("tabindex", "0"); rowSpan.setAttribute( @@ -1286,14 +1301,22 @@ class AssetSearchHandler { li.dataset.fileId = file.id; const rowSpan = document.createElement("span"); + rowSpan.className = "file-browser-row"; + rowSpan.setAttribute("role", "option"); + rowSpan.setAttribute( + "aria-selected", + isSelected ? "true" : "false", + ); rowSpan.setAttribute("tabindex", "0"); rowSpan.innerHTML = ` - - ${file.name} + ${isExistingFile ? "disabled" : ""} + aria-hidden="true" + tabindex="-1"> + + ${file.name} `; @@ -1301,6 +1324,18 @@ class AssetSearchHandler { const checkbox = rowSpan.querySelector('input[type="checkbox"]'); if (!isExistingFile) { + const syncRowSelectionVisual = () => { + li.classList.toggle("is-selected", checkbox.checked); + rowSpan.setAttribute( + "aria-selected", + checkbox.checked ? "true" : "false", + ); + }; + + if (isSelected) { + li.classList.add("is-selected"); + } + checkbox.addEventListener("change", (e) => { e.stopPropagation(); if (checkbox.checked) { @@ -1311,16 +1346,28 @@ class AssetSearchHandler { } else { this.selectedFiles.delete(file.id); } + syncRowSelectionVisual(); this.updateSelectAllCheckboxState(); this.updateSelectedFilesList(); }); + const toggleRowSelection = () => { + checkbox.checked = !checkbox.checked; + checkbox.dispatchEvent(new Event("change")); + }; + rowSpan.addEventListener("click", (e) => { if (e.target.type === "checkbox") { return; } - checkbox.checked = !checkbox.checked; - checkbox.dispatchEvent(new Event("change")); + toggleRowSelection(); + }); + + rowSpan.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleRowSelection(); + } }); li.classList.add("clickable-row"); From ba0eb466407a3c0958d58bdf67f9fd276859d129 Mon Sep 17 00:00:00 2001 From: klpoland Date: Thu, 4 Jun 2026 09:05:50 -0400 Subject: [PATCH 03/11] linting --- gateway/sds_gateway/static/css/components.css | 2 +- .../static/css/spectrumx_theme.css | 80 +- .../static/js/core/PageLifecycleManager.js | 30 +- .../js/dataset/DatasetCreationHandler.js | 1636 ++++++----- .../js/dataset/DatasetEditingHandler.js | 2604 +++++++++-------- .../static/js/search/AssetSearchHandler.js | 1858 ++++++------ .../__tests__/AssetSearchHandler.test.js | 864 +++--- .../users/partials/file_browser.html | 4 +- 8 files changed, 3588 insertions(+), 3490 deletions(-) diff --git a/gateway/sds_gateway/static/css/components.css b/gateway/sds_gateway/static/css/components.css index 67fcb7836..973aa8ca3 100644 --- a/gateway/sds_gateway/static/css/components.css +++ b/gateway/sds_gateway/static/css/components.css @@ -1128,7 +1128,7 @@ body { } #file-tree-root { - margin-bottom: 0; + margin-bottom: 0; } .action-buttons { diff --git a/gateway/sds_gateway/static/css/spectrumx_theme.css b/gateway/sds_gateway/static/css/spectrumx_theme.css index 25088cd90..dcd38d467 100644 --- a/gateway/sds_gateway/static/css/spectrumx_theme.css +++ b/gateway/sds_gateway/static/css/spectrumx_theme.css @@ -268,80 +268,80 @@ textarea:focus-visible { } .file-browser-modal { - max-width: none; - width: 100%; + max-width: none; + width: 100%; } /* Modal file picker: compact rows, hidden checkboxes, click-to-select */ .file-browser-modal #file-tree-root { - padding-left: 0; + padding-left: 0; } .file-browser-modal .file-browser-row { - display: flex; - align-items: center; - justify-content: flex-start; - gap: 0.5rem; - padding: 0.375rem 0.75rem; - border-radius: 6px; - cursor: pointer; - transition: background-color 0.15s ease, color 0.15s ease; - width: 100%; - box-sizing: border-box; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.5rem; + padding: 0.375rem 0.75rem; + border-radius: 6px; + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; + width: 100%; + box-sizing: border-box; } .file-browser-modal .file-item > .file-browser-row:hover { - background-color: rgba(0, 90, 156, 0.08); + background-color: rgba(0, 90, 156, 0.08); } .file-browser-modal .file-item.is-selected > .file-browser-row { - background-color: rgba(0, 90, 156, 0.16); - color: #003d6b; - font-weight: 500; + background-color: rgba(0, 90, 156, 0.16); + color: #003d6b; + font-weight: 500; } .file-browser-modal .file-item.is-selected > .file-browser-row .bi { - color: #005a9c; + color: #005a9c; } .file-browser-modal .folder-item > .file-browser-row:hover { - background-color: #e9ecef; + background-color: #e9ecef; } .file-browser-modal .file-item.readonly-row > .file-browser-row { - cursor: not-allowed; - opacity: 0.65; + cursor: not-allowed; + opacity: 0.65; } .file-browser-modal .file-item.readonly-row > .file-browser-row:hover { - background-color: transparent; + background-color: transparent; } .file-browser-modal .item-content { - display: flex; - align-items: center; - gap: 0.5rem; - flex: 1; - min-width: 0; - justify-content: flex-start; + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; + min-width: 0; + justify-content: flex-start; } .file-browser-modal .file-browser-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .file-browser-modal .file-checkbox { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } .file-browser-header { diff --git a/gateway/sds_gateway/static/js/core/PageLifecycleManager.js b/gateway/sds_gateway/static/js/core/PageLifecycleManager.js index 2b8a42a68..951a84a13 100644 --- a/gateway/sds_gateway/static/js/core/PageLifecycleManager.js +++ b/gateway/sds_gateway/static/js/core/PageLifecycleManager.js @@ -235,21 +235,21 @@ class PageLifecycleManager { this.managers.push(capturesSearchHandler) } - // Initialize files search handler - if (window.SearchHandler) { - const filesSearchHandler = new window.SearchHandler({ - searchFormId: "files-search-form", - searchButtonId: "search-files", - clearButtonId: "clear-files-search", - tableBodyId: "file-tree-root", - paginationContainerId: "files-pagination", - type: "files", - formHandler: this.datasetModeManager?.getHandler(), - isEditMode: this.datasetModeManager?.isInEditMode() || false, - }); - this.managers.push(filesSearchHandler); - } - } + // Initialize files search handler + if (window.SearchHandler) { + const filesSearchHandler = new window.SearchHandler({ + searchFormId: "files-search-form", + searchButtonId: "search-files", + clearButtonId: "clear-files-search", + tableBodyId: "file-tree-root", + paginationContainerId: "files-pagination", + type: "files", + formHandler: this.datasetModeManager?.getHandler(), + isEditMode: this.datasetModeManager?.isInEditMode() || false, + }) + this.managers.push(filesSearchHandler) + } + } /** * Initialize sort functionality diff --git a/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js b/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js index de15c1e82..fb219785a 100644 --- a/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js +++ b/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js @@ -3,817 +3,831 @@ * Handles dataset creation workflow and form management */ class DatasetCreationHandler extends BaseManager { - /** - * Initialize dataset creation handler - * @param {Object} config - Configuration object - */ - constructor(config) { - super(); - this.currentUserId = config.currentUserId; - this.form = document.getElementById(config.formId); - this.steps = config.steps || []; - this.currentStep = 0; - this.onStepChange = config.onStepChange; - - // Navigation elements - this.prevBtn = document.getElementById("prevStep"); - this.nextBtn = document.getElementById("nextStep"); - this.submitBtn = document.getElementById("submitForm"); - this.stepTabs = document.querySelectorAll("#stepTabs .btn"); - - // Form fields - this.nameField = document.getElementById("id_name"); - this.authorsField = document.getElementById("id_authors"); - this.statusField = document.getElementById("id_status"); - this.descriptionField = document.getElementById("id_description"); - this.visibilityField = document.querySelector( - 'input[name="is_public"]:checked', - ); - - // Hidden fields - this.selectedCapturesField = document.getElementById("selected_captures"); - this.selectedFilesField = document.getElementById("selected_files"); - - // Selections - this.selectedCaptures = new Set(); // Set of capture IDs - this.selectedFiles = new Set(); // Set of file objects for the main card - this.selectedCaptureDetails = new Map(); // Map of capture ID -> capture details - - // File browser modal state (intermediate selections) - this.modalSelectedFiles = new Set(); // Set of file objects for modal intermediate state - - // Search handlers - this.capturesSearchHandler = null; - this.filesSearchHandler = null; - - this.initializeEventListeners(); - this.initializeErrorContainer(); - this.initializeAuthorsManagement(); - this.initializePlaceholders(); - this.validateCurrentStep(); - this.updateNavigation(); - } - - /** - * Initialize event listeners - */ - initializeEventListeners() { - // Initialize search handlers if they exist - if (window.AssetSearchHandler) { - this.capturesSearchHandler = new window.AssetSearchHandler({ - searchFormId: "captures-search-form", - searchButtonId: "search-captures", - clearButtonId: "clear-captures-search", - tableBodyId: "captures-table-body", - paginationContainerId: "captures-pagination", - type: "captures", - formHandler: this, - isEditMode: false, - apiEndpoint: window.location.pathname, - }); - - this.filesSearchHandler = new window.AssetSearchHandler({ - searchFormId: "files-search-form", - searchButtonId: "search-files", - clearButtonId: "clear-files-search", - tableBodyId: "file-tree-root", - paginationContainerId: "files-pagination", - confirmFileSelectionId: "confirm-file-selection", - type: "files", - formHandler: this, - isEditMode: false, - apiEndpoint: window.location.pathname, - }); - - // Initialize captures search to show initial state - if ( - this.capturesSearchHandler && - typeof this.capturesSearchHandler.initializeCapturesSearch === - "function" - ) { - this.capturesSearchHandler.initializeCapturesSearch(); - } - } - - // Prevent form submission on enter key - if (this.form) { - this.form.addEventListener("keypress", (e) => { - if (e.key === "Enter") { - e.preventDefault(); - } - }); - } - - // Navigation buttons - if (this.prevBtn) { - this.prevBtn.addEventListener("click", (e) => { - e.stopPropagation(); - this.navigateStep(-1); - }); - } - - if (this.nextBtn) { - this.nextBtn.addEventListener("click", (e) => { - e.stopPropagation(); - this.navigateStep(1); - }); - } - - // Step tab handlers - for (const [index, tab] of this.stepTabs.entries()) { - tab.addEventListener("click", () => { - if (index <= this.currentStep) { - this.currentStep = index; - this.updateNavigation(); - if (this.onStepChange) { - this.onStepChange(this.currentStep); - } - } - }); - } - - // Form field validation - if (this.nameField) { - this.nameField.addEventListener("input", () => - this.validateCurrentStep(), - ); - } - if (this.authorsField) { - this.authorsField.addEventListener("input", () => - this.validateCurrentStep(), - ); - } - if (this.statusField) { - this.statusField.addEventListener("change", () => - this.validateCurrentStep(), - ); - } - - // Capture selection handler (direct table selection) - document.addEventListener("change", (e) => { - if (e.target.matches('input[name="captures"]')) { - this.handleCaptureSelection(e.target); - } - }); - - // File browser modal handlers - this.initializeFileBrowserModal(); - } - - /** - * Initialize error container - */ - initializeErrorContainer() { - const errorContainer = document.getElementById("formErrors"); - if (!errorContainer) return; - - window.DOMUtils.hide(errorContainer); - } - - /** - * Initialize placeholder text for empty tables - */ - initializePlaceholders() { - // Initialize selected files table with placeholder - const selectedFilesTable = document.getElementById("selected-files-table"); - const selectedFilesBody = selectedFilesTable?.querySelector("tbody"); - if (selectedFilesBody && selectedFilesBody.innerHTML.trim() === "") { - selectedFilesBody.innerHTML = - 'No files selected'; - } - - // Initialize captures selection table with placeholder - const capturesSelectionTable = document.getElementById( - "captures-table-body", - ); - if ( - capturesSelectionTable && - capturesSelectionTable.innerHTML.trim() === "" - ) { - capturesSelectionTable.innerHTML = - 'No captures found'; - } - - // Initialize captures table on review step with placeholder (will be updated later) - const capturesTable = document.querySelector( - "#step5 .captures-table tbody", - ); - if (capturesTable && capturesTable.innerHTML.trim() === "") { - capturesTable.innerHTML = - 'No captures selected'; - } - - // Initialize files table on review step with placeholder (will be updated later) - const filesTable = document.querySelector("#step5 .files-table tbody"); - if (filesTable && filesTable.innerHTML.trim() === "") { - filesTable.innerHTML = - 'No files selected'; - } - } - - /** - * Initialize file browser modal handlers - */ - initializeFileBrowserModal() { - // Modal file selection handlers - document.addEventListener("change", (e) => { - if (e.target.matches('#file-tree-root input[name="files"]')) { - this.handleModalFileSelection(e.target); - } - }); - - // Select all files checkbox - const selectAllCheckbox = document.getElementById( - "select-all-files-checkbox", - ); - if (selectAllCheckbox) { - selectAllCheckbox.addEventListener("change", (e) => { - this.handleSelectAllFiles(e.target.checked); - }); - } - - // Confirm file selection button - const confirmButton = document.getElementById("confirm-file-selection"); - if (confirmButton) { - confirmButton.addEventListener("click", () => { - this.confirmFileSelection(); - }); - } - - window.AuthorsManager?.bindFileTreeModalHandlers(this); - - // Remove all files button - const removeAllButton = document.getElementById( - "remove-all-selected-files-button", - ); - if (!removeAllButton) return; - - removeAllButton.addEventListener("click", () => { - this.removeAllSelectedFiles(); - }); - } - - /** - * Handle capture selection from direct table - * @param {Element} checkbox - Checkbox element - */ - handleCaptureSelection(checkbox) { - const captureId = checkbox.value; - const row = checkbox.closest("tr"); - - if (checkbox.checked) { - // Add to selected captures - this.selectedCaptures.add(captureId); - - // Highlight the row - if (row) { - row.classList.add("table-warning"); - } - - // Store capture details if available from search handler - if (this.capturesSearchHandler?.selectedCaptureDetails) { - const captureDetails = - this.capturesSearchHandler.selectedCaptureDetails.get(captureId); - if (captureDetails) { - this.selectedCaptureDetails.set(captureId, captureDetails); - } - } - } else { - // Remove from selected captures - this.selectedCaptures.delete(captureId); - this.selectedCaptureDetails.delete(captureId); - - // Remove highlight from row - if (row) { - row.classList.remove("table-warning"); - } - } - - this.updateHiddenFields(); - void this.updateSelectedCapturesPanel(); - } - - /** - * Handle modal file selection (intermediate state) - * @param {Element} checkbox - Checkbox element - */ - handleModalFileSelection(checkbox) { - const fileId = checkbox.value; - const row = checkbox.closest("tr"); - - // Get file data from the row - const fileData = this.getFileDataFromRow(row); - - if (checkbox.checked) { - // Add to modal intermediate selection - this.modalSelectedFiles.add(fileData); - } else { - // Remove from modal intermediate selection - this.modalSelectedFiles.delete(fileData); - } - - // Update select all checkbox state - this.updateSelectAllCheckbox(); - } - - /** - * Get file data from table row - * @param {Element} row - Table row element - * @returns {Object} File data object - */ - getFileDataFromRow(row) { - const cells = row.querySelectorAll("td"); - return { - id: row.querySelector('input[name="files"]').value, - name: cells[1]?.textContent?.trim() || "", - media_type: cells[2]?.textContent?.trim() || "", - relative_path: cells[3]?.textContent?.trim() || "", - size: cells[4]?.textContent?.trim() || "", - created_at: cells[5]?.textContent?.trim() || "", - }; - } - - /** - * Handle select all files checkbox - * @param {boolean} checked - Whether select all is checked - */ - handleSelectAllFiles(checked) { - const checkboxes = document.querySelectorAll( - '#file-tree-root input[name="files"]', - ); - for (const checkbox of checkboxes) { - checkbox.checked = checked; - this.handleModalFileSelection(checkbox); - } - } - - /** - * Update select all checkbox state - */ - updateSelectAllCheckbox() { - const selectAllCheckbox = document.getElementById( - "select-all-files-checkbox", - ); - const allCheckboxes = document.querySelectorAll( - '#file-tree-root input[name="files"]', - ); - - if (selectAllCheckbox && allCheckboxes.length > 0) { - const checkedCount = Array.from(allCheckboxes).filter( - (cb) => cb.checked, - ).length; - selectAllCheckbox.checked = checkedCount === allCheckboxes.length; - selectAllCheckbox.indeterminate = - checkedCount > 0 && checkedCount < allCheckboxes.length; - } - } - - /** - * Confirm file selection (move from modal to main selection) - */ - confirmFileSelection() { - // Add modal selections to main selection - for (const file of this.modalSelectedFiles) { - this.selectedFiles.add(file); - } - - // Clear modal selections - this.modalSelectedFiles.clear(); - - // Update UI - this.updateSelectedFilesDisplay(); - this.updateHiddenFields(); - - // Close modal - const modal = bootstrap.Modal.getInstance( - document.getElementById("fileTreeModal"), - ); - if (modal) { - modal.hide(); - } - } - - /** - * Handle file modal show - */ - onFileModalShow() { - // Trigger initial file tree loading if filesSearchHandler exists and tree hasn't been loaded - if (this.filesSearchHandler && !this.filesSearchHandler.currentTree) { - this.filesSearchHandler.handleSearch(); - } - } - - /** - * Handle file modal hide - */ - onFileModalHide() { - // Clear any intermediate state if needed - this.modalSelectedFiles.clear(); - } - - /** - * Remove all selected files - */ - removeAllSelectedFiles() { - // Clear main selection - this.selectedFiles.clear(); - - // Clear modal intermediate selection - this.modalSelectedFiles.clear(); - - // Update UI - this.updateSelectedFilesDisplay(); - this.updateHiddenFields(); - - // Uncheck all checkboxes in modal if it's open - const checkboxes = document.querySelectorAll( - '#file-tree-root input[name="files"]', - ); - for (const checkbox of checkboxes) { - checkbox.checked = false; - } - - // Update select all checkbox - this.updateSelectAllCheckbox(); - } - - /** - * Update selected files display - */ - updateSelectedFilesDisplay() { - const displayInput = document.getElementById("selected-files-display"); - if (!displayInput) return; - - const count = this.selectedFiles.size; - displayInput.value = `${count} file(s) selected`; - - // Update selected files table - this.updateSelectedFilesTable(); - } - - /** - * Navigate between steps - * @param {number} direction - Direction to navigate (-1 or 1) - */ - async navigateStep(direction) { - if (direction < 0 || this.validateCurrentStep()) { - const nextStep = this.currentStep + direction; - - // Update review step if moving to it (step 5 = index 4) - if (nextStep === 4) { - this.updateReviewStep(); - } - - this.currentStep = nextStep; - this.updateNavigation(); - this.updateDatasetNameDisplay(); - - if (this.onStepChange) { - this.onStepChange(this.currentStep); - } - } - } - - /** - * Update review step content - */ - updateReviewStep() { - // Update dataset name - const nameDisplay = document.querySelector("#step5 .dataset-name"); - if (nameDisplay) { - nameDisplay.textContent = this.nameField ? this.nameField.value : ""; - } - - // Update authors display - if (window.updateReviewAuthorsDisplay) { - window.updateReviewAuthorsDisplay(); - } else { - this.updateAuthorsDisplayFallback(); - } - - // Status and visibility are now handled in publishing info panel by DatasetModeManager - - // Update description display - const descriptionDisplay = document.querySelector( - "#step5 .dataset-description", - ); - if (descriptionDisplay) { - descriptionDisplay.textContent = this.descriptionField - ? this.descriptionField.value - : "No description provided."; - } - - // Update selected items table - this.updateSelectedItemsTable(); - } - - /** - * Update authors display fallback - */ - updateAuthorsDisplayFallback() { - const authorsField = document.getElementById("id_authors"); - const authorsDisplay = document.querySelector("#step5 .dataset-authors"); - - if (authorsField?.value && authorsDisplay) { - try { - const authors = JSON.parse(authorsField.value); - const authorNames = authors.map((author) => { - if (typeof author === "string") { - return author; - } - if (author?.name) { - return author.name; - } - return "Unnamed Author"; - }); - authorsDisplay.textContent = authorNames.join(", "); - } catch (e) { - authorsDisplay.textContent = authorsField.value; - } - } else if (authorsDisplay) { - authorsDisplay.textContent = "No authors specified"; - } - } - - /** - * Update dataset name display - */ - updateDatasetNameDisplay() { - const nameDisplays = document.getElementsByClassName( - "dataset-name-display", - ); - if (this.nameField && nameDisplays.length > 0) { - for (const nameDisplay of Array.from(nameDisplays)) { - nameDisplay.textContent = this.nameField.value || "Untitled Dataset"; - } - } - } - - /** - * Update navigation state - */ - updateNavigation() { - // Update step tabs - for (const [index, tab] of this.stepTabs.entries()) { - tab.classList.remove( - "btn-outline-primary", - "btn-primary", - "active-tab", - "inactive-tab", - ); - - if (index === this.currentStep) { - tab.classList.add("btn-primary", "active-tab"); - } else if (index > this.currentStep) { - tab.classList.add("btn-outline-primary", "inactive-tab"); - } else { - tab.classList.add("btn-primary", "inactive-tab"); - } - } - - // Update content panes - for (const [index, pane] of document - .querySelectorAll(".tab-pane") - .entries()) { - pane.classList.remove("show", "active"); - if (index === this.currentStep) { - pane.classList.add("show", "active"); - } - } - - // Update navigation buttons - if (this.prevBtn) { - if (this.currentStep > 0) { - window.DOMUtils.show(this.prevBtn); - } else { - window.DOMUtils.hide(this.prevBtn); - } - } - - const isValid = this.validateCurrentStep(); - - if (this.nextBtn) { - const isLastStep = this.currentStep === this.steps.length - 1; - if (isLastStep) { - window.DOMUtils.hide(this.nextBtn); - } else { - window.DOMUtils.show(this.nextBtn); - } - this.nextBtn.disabled = !isValid; - } - - if (this.submitBtn) { - // Only show submit button on final step (step 5, index 4) - if (this.currentStep === 4) { - window.DOMUtils.show(this.submitBtn, "display-inline-block"); - this.submitBtn.disabled = !isValid; - } else { - window.DOMUtils.hide(this.submitBtn, "display-inline-block"); - } - } - } - - /** - * Validate current step - * @returns {boolean} Whether current step is valid - */ - validateCurrentStep() { - let isValid = true; - - switch (this.currentStep) { - case 0: - isValid = this.validateDatasetInfo(); - break; - case 1: - isValid = this.validateCapturesSelection(); - break; - case 2: - isValid = this.validateFilesSelection(); - break; - default: - isValid = true; - } - - // Update button states - if (this.nextBtn) { - this.nextBtn.disabled = !isValid; - } - if (this.submitBtn && this.currentStep === 4) { - this.submitBtn.disabled = !isValid; - } - - return isValid; - } - - /** - * Validate dataset info step - * @returns {boolean} Whether dataset info is valid - */ - validateDatasetInfo() { - const nameValue = this.nameField?.value.trim() || ""; - const authorsValue = this.authorsField?.value.trim() || ""; - - // Validate authors JSON and first author name - if (authorsValue) { - try { - const authors = JSON.parse(authorsValue); - if (!Array.isArray(authors) || authors.length === 0) { - return false; - } - - // Check that the first author has a name - const firstAuthor = authors[0]; - if ( - !firstAuthor || - !firstAuthor.name || - firstAuthor.name.trim() === "" - ) { - return false; - } - } catch (e) { - return false; - } - } else { - return false; // Authors field is required - } - - return nameValue !== ""; - } - - /** - * Validate captures selection step - * @returns {boolean} Whether captures selection is valid - */ - validateCapturesSelection() { - return true; // Captures selection is optional - } - - /** - * Validate files selection step - * @returns {boolean} Whether files selection is valid - */ - validateFilesSelection() { - return true; // Files selection is optional - } - - /** - * Handle form submission - * @param {Event} e - Submit event - */ - async handleSubmit(e) { - e.preventDefault(); - - if (!this.validateCurrentStep()) { - return; - } - - // Set loading state - this.setSubmitButtonLoading(true); - - // Update hidden fields - this.updateHiddenFields(); - - // Clear existing errors - this.clearErrors(); - - const formData = new FormData(this.form); - - try { - const response = await window.APIClient.request(this.form.action, { - method: "POST", - body: formData, - }); - - if (response.success) { - window.location.href = response.redirect_url; - } else if (response.errors) { - throw new APIError("Validation failed", 400, response.errors); - } - } catch (error) { - console.error("Error submitting form:", error); - this.handleSubmissionError(error); - } finally { - this.setSubmitButtonLoading(false); - } - } - - /** - * Handle submission error - * @param {Error} error - Error object - */ - async handleSubmissionError(error) { - const errorContainer = document.getElementById("formErrors"); - if (!errorContainer) return; - - try { - // Normalize error context - const context = {}; - - if (error instanceof APIError && error.data.errors) { - // Normalize field errors into list format for template - context.error_list = []; - for (const [field, messages] of Object.entries(error.data.errors)) { - const messageList = Array.isArray(messages) ? messages : [messages]; - for (const msg of messageList) { - context.error_list.push([field, msg]); - } - } - context.show_field_names = true; - } else { - context.message = "An unexpected error occurred. Please try again."; - } - - const messageText = context.message ?? ""; - const templateContext = { - alert_type: "danger", - icon: "exclamation-triangle-fill", - }; - if (context.error_list) { - templateContext.error_list = context.error_list; - templateContext.show_field_names = context.show_field_names; - } - - const success = await window.DOMUtils.showMessage(messageText, { - variant: "danger", - placement: "replace", - target: errorContainer, - presentation: "alert", - templateContext, - }); - if (success) { - window.DOMUtils.show(errorContainer); - errorContainer.scrollIntoView({ behavior: "smooth", block: "nearest" }); - } - } catch (err) { - console.error("Error rendering error message:", err); - // Fallback to simple text - errorContainer.textContent = "An error occurred. Please try again."; - window.DOMUtils.show(errorContainer); - } - } - - /** - * Clear form errors - */ - clearErrors() { - const errorContainer = document.getElementById("formErrors"); - if (errorContainer) { - errorContainer.innerHTML = ""; - window.DOMUtils.hide(errorContainer); - } - } - - /** - * Set submit button loading state - * @param {boolean} isLoading - Whether button is loading - */ - setSubmitButtonLoading(isLoading) { - if (!this.submitBtn) return; - - if (isLoading) { - this.submitBtn.dataset.originalText = this.submitBtn.textContent; - this.submitBtn.disabled = true; - this.submitBtn.innerHTML = ` + /** + * Initialize dataset creation handler + * @param {Object} config - Configuration object + */ + constructor(config) { + super() + this.currentUserId = config.currentUserId + this.form = document.getElementById(config.formId) + this.steps = config.steps || [] + this.currentStep = 0 + this.onStepChange = config.onStepChange + + // Navigation elements + this.prevBtn = document.getElementById("prevStep") + this.nextBtn = document.getElementById("nextStep") + this.submitBtn = document.getElementById("submitForm") + this.stepTabs = document.querySelectorAll("#stepTabs .btn") + + // Form fields + this.nameField = document.getElementById("id_name") + this.authorsField = document.getElementById("id_authors") + this.statusField = document.getElementById("id_status") + this.descriptionField = document.getElementById("id_description") + this.visibilityField = document.querySelector( + 'input[name="is_public"]:checked', + ) + + // Hidden fields + this.selectedCapturesField = + document.getElementById("selected_captures") + this.selectedFilesField = document.getElementById("selected_files") + + // Selections + this.selectedCaptures = new Set() // Set of capture IDs + this.selectedFiles = new Set() // Set of file objects for the main card + this.selectedCaptureDetails = new Map() // Map of capture ID -> capture details + + // File browser modal state (intermediate selections) + this.modalSelectedFiles = new Set() // Set of file objects for modal intermediate state + + // Search handlers + this.capturesSearchHandler = null + this.filesSearchHandler = null + + this.initializeEventListeners() + this.initializeErrorContainer() + this.initializeAuthorsManagement() + this.initializePlaceholders() + this.validateCurrentStep() + this.updateNavigation() + } + + /** + * Initialize event listeners + */ + initializeEventListeners() { + // Initialize search handlers if they exist + if (window.AssetSearchHandler) { + this.capturesSearchHandler = new window.AssetSearchHandler({ + searchFormId: "captures-search-form", + searchButtonId: "search-captures", + clearButtonId: "clear-captures-search", + tableBodyId: "captures-table-body", + paginationContainerId: "captures-pagination", + type: "captures", + formHandler: this, + isEditMode: false, + apiEndpoint: window.location.pathname, + }) + + this.filesSearchHandler = new window.AssetSearchHandler({ + searchFormId: "files-search-form", + searchButtonId: "search-files", + clearButtonId: "clear-files-search", + tableBodyId: "file-tree-root", + paginationContainerId: "files-pagination", + confirmFileSelectionId: "confirm-file-selection", + type: "files", + formHandler: this, + isEditMode: false, + apiEndpoint: window.location.pathname, + }) + + // Initialize captures search to show initial state + if ( + this.capturesSearchHandler && + typeof this.capturesSearchHandler.initializeCapturesSearch === + "function" + ) { + this.capturesSearchHandler.initializeCapturesSearch() + } + } + + // Prevent form submission on enter key + if (this.form) { + this.form.addEventListener("keypress", (e) => { + if (e.key === "Enter") { + e.preventDefault() + } + }) + } + + // Navigation buttons + if (this.prevBtn) { + this.prevBtn.addEventListener("click", (e) => { + e.stopPropagation() + this.navigateStep(-1) + }) + } + + if (this.nextBtn) { + this.nextBtn.addEventListener("click", (e) => { + e.stopPropagation() + this.navigateStep(1) + }) + } + + // Step tab handlers + for (const [index, tab] of this.stepTabs.entries()) { + tab.addEventListener("click", () => { + if (index <= this.currentStep) { + this.currentStep = index + this.updateNavigation() + if (this.onStepChange) { + this.onStepChange(this.currentStep) + } + } + }) + } + + // Form field validation + if (this.nameField) { + this.nameField.addEventListener("input", () => + this.validateCurrentStep(), + ) + } + if (this.authorsField) { + this.authorsField.addEventListener("input", () => + this.validateCurrentStep(), + ) + } + if (this.statusField) { + this.statusField.addEventListener("change", () => + this.validateCurrentStep(), + ) + } + + // Capture selection handler (direct table selection) + document.addEventListener("change", (e) => { + if (e.target.matches('input[name="captures"]')) { + this.handleCaptureSelection(e.target) + } + }) + + // File browser modal handlers + this.initializeFileBrowserModal() + } + + /** + * Initialize error container + */ + initializeErrorContainer() { + const errorContainer = document.getElementById("formErrors") + if (!errorContainer) return + + window.DOMUtils.hide(errorContainer) + } + + /** + * Initialize placeholder text for empty tables + */ + initializePlaceholders() { + // Initialize selected files table with placeholder + const selectedFilesTable = document.getElementById( + "selected-files-table", + ) + const selectedFilesBody = selectedFilesTable?.querySelector("tbody") + if (selectedFilesBody && selectedFilesBody.innerHTML.trim() === "") { + selectedFilesBody.innerHTML = + 'No files selected' + } + + // Initialize captures selection table with placeholder + const capturesSelectionTable = document.getElementById( + "captures-table-body", + ) + if ( + capturesSelectionTable && + capturesSelectionTable.innerHTML.trim() === "" + ) { + capturesSelectionTable.innerHTML = + 'No captures found' + } + + // Initialize captures table on review step with placeholder (will be updated later) + const capturesTable = document.querySelector( + "#step5 .captures-table tbody", + ) + if (capturesTable && capturesTable.innerHTML.trim() === "") { + capturesTable.innerHTML = + 'No captures selected' + } + + // Initialize files table on review step with placeholder (will be updated later) + const filesTable = document.querySelector("#step5 .files-table tbody") + if (filesTable && filesTable.innerHTML.trim() === "") { + filesTable.innerHTML = + 'No files selected' + } + } + + /** + * Initialize file browser modal handlers + */ + initializeFileBrowserModal() { + // Modal file selection handlers + document.addEventListener("change", (e) => { + if (e.target.matches('#file-tree-root input[name="files"]')) { + this.handleModalFileSelection(e.target) + } + }) + + // Select all files checkbox + const selectAllCheckbox = document.getElementById( + "select-all-files-checkbox", + ) + if (selectAllCheckbox) { + selectAllCheckbox.addEventListener("change", (e) => { + this.handleSelectAllFiles(e.target.checked) + }) + } + + // Confirm file selection button + const confirmButton = document.getElementById("confirm-file-selection") + if (confirmButton) { + confirmButton.addEventListener("click", () => { + this.confirmFileSelection() + }) + } + + window.AuthorsManager?.bindFileTreeModalHandlers(this) + + // Remove all files button + const removeAllButton = document.getElementById( + "remove-all-selected-files-button", + ) + if (!removeAllButton) return + + removeAllButton.addEventListener("click", () => { + this.removeAllSelectedFiles() + }) + } + + /** + * Handle capture selection from direct table + * @param {Element} checkbox - Checkbox element + */ + handleCaptureSelection(checkbox) { + const captureId = checkbox.value + const row = checkbox.closest("tr") + + if (checkbox.checked) { + // Add to selected captures + this.selectedCaptures.add(captureId) + + // Highlight the row + if (row) { + row.classList.add("table-warning") + } + + // Store capture details if available from search handler + if (this.capturesSearchHandler?.selectedCaptureDetails) { + const captureDetails = + this.capturesSearchHandler.selectedCaptureDetails.get( + captureId, + ) + if (captureDetails) { + this.selectedCaptureDetails.set(captureId, captureDetails) + } + } + } else { + // Remove from selected captures + this.selectedCaptures.delete(captureId) + this.selectedCaptureDetails.delete(captureId) + + // Remove highlight from row + if (row) { + row.classList.remove("table-warning") + } + } + + this.updateHiddenFields() + void this.updateSelectedCapturesPanel() + } + + /** + * Handle modal file selection (intermediate state) + * @param {Element} checkbox - Checkbox element + */ + handleModalFileSelection(checkbox) { + const fileId = checkbox.value + const row = checkbox.closest("tr") + + // Get file data from the row + const fileData = this.getFileDataFromRow(row) + + if (checkbox.checked) { + // Add to modal intermediate selection + this.modalSelectedFiles.add(fileData) + } else { + // Remove from modal intermediate selection + this.modalSelectedFiles.delete(fileData) + } + + // Update select all checkbox state + this.updateSelectAllCheckbox() + } + + /** + * Get file data from table row + * @param {Element} row - Table row element + * @returns {Object} File data object + */ + getFileDataFromRow(row) { + const cells = row.querySelectorAll("td") + return { + id: row.querySelector('input[name="files"]').value, + name: cells[1]?.textContent?.trim() || "", + media_type: cells[2]?.textContent?.trim() || "", + relative_path: cells[3]?.textContent?.trim() || "", + size: cells[4]?.textContent?.trim() || "", + created_at: cells[5]?.textContent?.trim() || "", + } + } + + /** + * Handle select all files checkbox + * @param {boolean} checked - Whether select all is checked + */ + handleSelectAllFiles(checked) { + const checkboxes = document.querySelectorAll( + '#file-tree-root input[name="files"]', + ) + for (const checkbox of checkboxes) { + checkbox.checked = checked + this.handleModalFileSelection(checkbox) + } + } + + /** + * Update select all checkbox state + */ + updateSelectAllCheckbox() { + const selectAllCheckbox = document.getElementById( + "select-all-files-checkbox", + ) + const allCheckboxes = document.querySelectorAll( + '#file-tree-root input[name="files"]', + ) + + if (selectAllCheckbox && allCheckboxes.length > 0) { + const checkedCount = Array.from(allCheckboxes).filter( + (cb) => cb.checked, + ).length + selectAllCheckbox.checked = checkedCount === allCheckboxes.length + selectAllCheckbox.indeterminate = + checkedCount > 0 && checkedCount < allCheckboxes.length + } + } + + /** + * Confirm file selection (move from modal to main selection) + */ + confirmFileSelection() { + // Add modal selections to main selection + for (const file of this.modalSelectedFiles) { + this.selectedFiles.add(file) + } + + // Clear modal selections + this.modalSelectedFiles.clear() + + // Update UI + this.updateSelectedFilesDisplay() + this.updateHiddenFields() + + // Close modal + const modal = bootstrap.Modal.getInstance( + document.getElementById("fileTreeModal"), + ) + if (modal) { + modal.hide() + } + } + + /** + * Handle file modal show + */ + onFileModalShow() { + // Trigger initial file tree loading if filesSearchHandler exists and tree hasn't been loaded + if (this.filesSearchHandler && !this.filesSearchHandler.currentTree) { + this.filesSearchHandler.handleSearch() + } + } + + /** + * Handle file modal hide + */ + onFileModalHide() { + // Clear any intermediate state if needed + this.modalSelectedFiles.clear() + } + + /** + * Remove all selected files + */ + removeAllSelectedFiles() { + // Clear main selection + this.selectedFiles.clear() + + // Clear modal intermediate selection + this.modalSelectedFiles.clear() + + // Update UI + this.updateSelectedFilesDisplay() + this.updateHiddenFields() + + // Uncheck all checkboxes in modal if it's open + const checkboxes = document.querySelectorAll( + '#file-tree-root input[name="files"]', + ) + for (const checkbox of checkboxes) { + checkbox.checked = false + } + + // Update select all checkbox + this.updateSelectAllCheckbox() + } + + /** + * Update selected files display + */ + updateSelectedFilesDisplay() { + const displayInput = document.getElementById("selected-files-display") + if (!displayInput) return + + const count = this.selectedFiles.size + displayInput.value = `${count} file(s) selected` + + // Update selected files table + this.updateSelectedFilesTable() + } + + /** + * Navigate between steps + * @param {number} direction - Direction to navigate (-1 or 1) + */ + async navigateStep(direction) { + if (direction < 0 || this.validateCurrentStep()) { + const nextStep = this.currentStep + direction + + // Update review step if moving to it (step 5 = index 4) + if (nextStep === 4) { + this.updateReviewStep() + } + + this.currentStep = nextStep + this.updateNavigation() + this.updateDatasetNameDisplay() + + if (this.onStepChange) { + this.onStepChange(this.currentStep) + } + } + } + + /** + * Update review step content + */ + updateReviewStep() { + // Update dataset name + const nameDisplay = document.querySelector("#step5 .dataset-name") + if (nameDisplay) { + nameDisplay.textContent = this.nameField ? this.nameField.value : "" + } + + // Update authors display + if (window.updateReviewAuthorsDisplay) { + window.updateReviewAuthorsDisplay() + } else { + this.updateAuthorsDisplayFallback() + } + + // Status and visibility are now handled in publishing info panel by DatasetModeManager + + // Update description display + const descriptionDisplay = document.querySelector( + "#step5 .dataset-description", + ) + if (descriptionDisplay) { + descriptionDisplay.textContent = this.descriptionField + ? this.descriptionField.value + : "No description provided." + } + + // Update selected items table + this.updateSelectedItemsTable() + } + + /** + * Update authors display fallback + */ + updateAuthorsDisplayFallback() { + const authorsField = document.getElementById("id_authors") + const authorsDisplay = document.querySelector("#step5 .dataset-authors") + + if (authorsField?.value && authorsDisplay) { + try { + const authors = JSON.parse(authorsField.value) + const authorNames = authors.map((author) => { + if (typeof author === "string") { + return author + } + if (author?.name) { + return author.name + } + return "Unnamed Author" + }) + authorsDisplay.textContent = authorNames.join(", ") + } catch (e) { + authorsDisplay.textContent = authorsField.value + } + } else if (authorsDisplay) { + authorsDisplay.textContent = "No authors specified" + } + } + + /** + * Update dataset name display + */ + updateDatasetNameDisplay() { + const nameDisplays = document.getElementsByClassName( + "dataset-name-display", + ) + if (this.nameField && nameDisplays.length > 0) { + for (const nameDisplay of Array.from(nameDisplays)) { + nameDisplay.textContent = + this.nameField.value || "Untitled Dataset" + } + } + } + + /** + * Update navigation state + */ + updateNavigation() { + // Update step tabs + for (const [index, tab] of this.stepTabs.entries()) { + tab.classList.remove( + "btn-outline-primary", + "btn-primary", + "active-tab", + "inactive-tab", + ) + + if (index === this.currentStep) { + tab.classList.add("btn-primary", "active-tab") + } else if (index > this.currentStep) { + tab.classList.add("btn-outline-primary", "inactive-tab") + } else { + tab.classList.add("btn-primary", "inactive-tab") + } + } + + // Update content panes + for (const [index, pane] of document + .querySelectorAll(".tab-pane") + .entries()) { + pane.classList.remove("show", "active") + if (index === this.currentStep) { + pane.classList.add("show", "active") + } + } + + // Update navigation buttons + if (this.prevBtn) { + if (this.currentStep > 0) { + window.DOMUtils.show(this.prevBtn) + } else { + window.DOMUtils.hide(this.prevBtn) + } + } + + const isValid = this.validateCurrentStep() + + if (this.nextBtn) { + const isLastStep = this.currentStep === this.steps.length - 1 + if (isLastStep) { + window.DOMUtils.hide(this.nextBtn) + } else { + window.DOMUtils.show(this.nextBtn) + } + this.nextBtn.disabled = !isValid + } + + if (this.submitBtn) { + // Only show submit button on final step (step 5, index 4) + if (this.currentStep === 4) { + window.DOMUtils.show(this.submitBtn, "display-inline-block") + this.submitBtn.disabled = !isValid + } else { + window.DOMUtils.hide(this.submitBtn, "display-inline-block") + } + } + } + + /** + * Validate current step + * @returns {boolean} Whether current step is valid + */ + validateCurrentStep() { + let isValid = true + + switch (this.currentStep) { + case 0: + isValid = this.validateDatasetInfo() + break + case 1: + isValid = this.validateCapturesSelection() + break + case 2: + isValid = this.validateFilesSelection() + break + default: + isValid = true + } + + // Update button states + if (this.nextBtn) { + this.nextBtn.disabled = !isValid + } + if (this.submitBtn && this.currentStep === 4) { + this.submitBtn.disabled = !isValid + } + + return isValid + } + + /** + * Validate dataset info step + * @returns {boolean} Whether dataset info is valid + */ + validateDatasetInfo() { + const nameValue = this.nameField?.value.trim() || "" + const authorsValue = this.authorsField?.value.trim() || "" + + // Validate authors JSON and first author name + if (authorsValue) { + try { + const authors = JSON.parse(authorsValue) + if (!Array.isArray(authors) || authors.length === 0) { + return false + } + + // Check that the first author has a name + const firstAuthor = authors[0] + if ( + !firstAuthor || + !firstAuthor.name || + firstAuthor.name.trim() === "" + ) { + return false + } + } catch (e) { + return false + } + } else { + return false // Authors field is required + } + + return nameValue !== "" + } + + /** + * Validate captures selection step + * @returns {boolean} Whether captures selection is valid + */ + validateCapturesSelection() { + return true // Captures selection is optional + } + + /** + * Validate files selection step + * @returns {boolean} Whether files selection is valid + */ + validateFilesSelection() { + return true // Files selection is optional + } + + /** + * Handle form submission + * @param {Event} e - Submit event + */ + async handleSubmit(e) { + e.preventDefault() + + if (!this.validateCurrentStep()) { + return + } + + // Set loading state + this.setSubmitButtonLoading(true) + + // Update hidden fields + this.updateHiddenFields() + + // Clear existing errors + this.clearErrors() + + const formData = new FormData(this.form) + + try { + const response = await window.APIClient.request(this.form.action, { + method: "POST", + body: formData, + }) + + if (response.success) { + window.location.href = response.redirect_url + } else if (response.errors) { + throw new APIError("Validation failed", 400, response.errors) + } + } catch (error) { + console.error("Error submitting form:", error) + this.handleSubmissionError(error) + } finally { + this.setSubmitButtonLoading(false) + } + } + + /** + * Handle submission error + * @param {Error} error - Error object + */ + async handleSubmissionError(error) { + const errorContainer = document.getElementById("formErrors") + if (!errorContainer) return + + try { + // Normalize error context + const context = {} + + if (error instanceof APIError && error.data.errors) { + // Normalize field errors into list format for template + context.error_list = [] + for (const [field, messages] of Object.entries( + error.data.errors, + )) { + const messageList = Array.isArray(messages) + ? messages + : [messages] + for (const msg of messageList) { + context.error_list.push([field, msg]) + } + } + context.show_field_names = true + } else { + context.message = + "An unexpected error occurred. Please try again." + } + + const messageText = context.message ?? "" + const templateContext = { + alert_type: "danger", + icon: "exclamation-triangle-fill", + } + if (context.error_list) { + templateContext.error_list = context.error_list + templateContext.show_field_names = context.show_field_names + } + + const success = await window.DOMUtils.showMessage(messageText, { + variant: "danger", + placement: "replace", + target: errorContainer, + presentation: "alert", + templateContext, + }) + if (success) { + window.DOMUtils.show(errorContainer) + errorContainer.scrollIntoView({ + behavior: "smooth", + block: "nearest", + }) + } + } catch (err) { + console.error("Error rendering error message:", err) + // Fallback to simple text + errorContainer.textContent = "An error occurred. Please try again." + window.DOMUtils.show(errorContainer) + } + } + + /** + * Clear form errors + */ + clearErrors() { + const errorContainer = document.getElementById("formErrors") + if (errorContainer) { + errorContainer.innerHTML = "" + window.DOMUtils.hide(errorContainer) + } + } + + /** + * Set submit button loading state + * @param {boolean} isLoading - Whether button is loading + */ + setSubmitButtonLoading(isLoading) { + if (!this.submitBtn) return + + if (isLoading) { + this.submitBtn.dataset.originalText = this.submitBtn.textContent + this.submitBtn.disabled = true + this.submitBtn.innerHTML = ` Creating... ` diff --git a/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js b/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js index 1428e83e7..19d18783d 100644 --- a/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js +++ b/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js @@ -3,1283 +3,1333 @@ * Handles dataset editing workflow with pending changes management */ class DatasetEditingHandler extends BaseManager { - /** - * Initialize dataset editing handler - * @param {Object} config - Configuration object - */ - constructor(config) { - super(); - this.datasetUuid = config.datasetUuid; - this.permissions = config.permissions; // PermissionsManager instance - this.currentUserId = config.currentUserId; - - // Current assets in dataset - this.currentCaptures = new Map(); - this.currentFiles = new Map(); - - // Pending changes - this.pendingCaptures = new Map(); // key: captureId, value: {action: 'add'|'remove', data: {...}} - this.pendingFiles = new Map(); // key: fileId, value: {action: 'add'|'remove', data: {...}} - - // Search handlers - this.capturesSearchHandler = null; - this.filesSearchHandler = null; - - // Properties that SearchHandler expects from formHandler - this.selectedCaptures = new Set(); - this.selectedFiles = new Set(); - - // Store initial data - this.initialCaptures = config.initialCaptures || []; - this.initialFiles = config.initialFiles || []; - - this.initializeEventListeners(); - this.initializeAuthorsManagement(); - - // Load current assets if no initial data provided - if (!this.initialCaptures.length && !this.initialFiles.length) { - this.loadCurrentAssets(); - } - } - - /** - * Initialize event listeners - */ - initializeEventListeners() { - // Initialize search handlers if they exist - if (window.AssetSearchHandler) { - this.capturesSearchHandler = new window.AssetSearchHandler({ - searchFormId: "captures-search-form", - searchButtonId: "search-captures", - clearButtonId: "clear-captures-search", - tableBodyId: "captures-table-body", - paginationContainerId: "captures-pagination", - type: "captures", - formHandler: this, - isEditMode: true, - }); - - this.filesSearchHandler = new window.AssetSearchHandler({ - searchFormId: "files-search-form", - searchButtonId: "search-files", - clearButtonId: "clear-files-search", - tableBodyId: "file-tree-root", - paginationContainerId: "files-pagination", - confirmFileSelectionId: "confirm-file-selection", - type: "files", - formHandler: this, - isEditMode: true, - apiEndpoint: window.location.pathname, - }); - - // Initialize captures search to show initial state - if ( - this.capturesSearchHandler && - typeof this.capturesSearchHandler.initializeCapturesSearch === - "function" - ) { - this.capturesSearchHandler.initializeCapturesSearch(); - } - - // Populate initial data now that handlers are ready - this.populateFromInitialData(this.initialCaptures, this.initialFiles); - } - - const datasetForm = document.getElementById("datasetForm"); - if (datasetForm && !datasetForm.dataset.enterSubmitGuardBound) { - datasetForm.dataset.enterSubmitGuardBound = "true"; - datasetForm.addEventListener("submit", (e) => { - e.preventDefault(); - }); - datasetForm.addEventListener("keypress", (e) => { - if (e.key === "Enter") { - e.preventDefault(); - } - }); - } - } - - /** - * Set search handler reference - * @param {Object} searchHandler - Search handler instance - * @param {string} type - Handler type (captures or files) - */ - setSearchHandler(searchHandler, type) { - if (type === "captures") { - this.capturesSearchHandler = searchHandler; - // Defer population until SearchHandler is fully ready - Promise.resolve().then(() => { - this.populateSearchHandlerWithInitialData(); - }); - } else if (type === "files") { - this.filesSearchHandler = searchHandler; - this.filesSearchHandler.updateSelectedFilesList(); - } - - // If we have initial data and both handlers are ready, populate the data - if ( - this.capturesSearchHandler && - this.filesSearchHandler && - (this.initialCaptures.length > 0 || this.initialFiles.length > 0) - ) { - this.populateFromInitialData(this.initialCaptures, this.initialFiles); - } - } - - /** - * Populate search handler with initial data - */ - populateSearchHandlerWithInitialData() { - // Populate the SearchHandler with initial captures if available - if ( - this.capturesSearchHandler?.selectedCaptures && - this.capturesSearchHandler.selectedCaptureDetails && - this.initialCaptures && - this.initialCaptures.length > 0 - ) { - for (const capture of this.initialCaptures) { - this.capturesSearchHandler.selectedCaptures.add(capture.id.toString()); - this.capturesSearchHandler.selectedCaptureDetails.set( - capture.id.toString(), - capture, - ); - } - } - // Also populate the DatasetEditingHandler's selectedCaptures set - if (this.initialCaptures && this.initialCaptures.length > 0) { - for (const capture of this.initialCaptures) { - this.selectedCaptures.add(capture.id.toString()); - } - } - } - - /** - * Populate from initial data - * @param {Array} initialCaptures - Initial captures data - * @param {Array} initialFiles - Initial files data - */ - populateFromInitialData(initialCaptures, initialFiles) { - // Populate current captures in the side panel table - this.currentCaptures.clear(); - this.populateCurrentCapturesList(initialCaptures); - - // Use the existing SearchHandler to populate captures in the main table - if (this.capturesSearchHandler?.selectedCaptures) { - if (initialCaptures && initialCaptures.length > 0) { - for (const capture of initialCaptures) { - this.capturesSearchHandler.selectedCaptures.add( - capture.id.toString(), - ); - this.capturesSearchHandler.selectedCaptureDetails.set( - capture.id.toString(), - capture, - ); - } - } - } - - // Also populate the DatasetEditingHandler's selectedCaptures set - if (initialCaptures && initialCaptures.length > 0) { - for (const captureId of initialCaptures) { - this.selectedCaptures.add(captureId.toString()); - } - } - - // Populate current files - this.currentFiles.clear(); - this.populateCurrentFilesList(initialFiles); - - // Use the existing SearchHandler to populate files for the file browser - if (this.filesSearchHandler) { - if (initialFiles && initialFiles.length > 0) { - for (const file of initialFiles) { - this.filesSearchHandler.selectedFiles.set(file.id, file); - } - } - this.filesSearchHandler.updateSelectedFilesList(); - } - - // Add event listeners for remove buttons - this.addRemoveButtonListeners(); - - // Initialize file browser modal handlers - this.initializeFileBrowserModal(); - } - - /** - * Initialize file browser modal handlers - */ - initializeFileBrowserModal() { - window.AuthorsManager?.bindFileTreeModalHandlers(this); - } - - /** - * Handle file modal show - */ - onFileModalShow() { - // Trigger initial file tree loading if filesSearchHandler exists and tree hasn't been loaded - if (this.filesSearchHandler && !this.filesSearchHandler.currentTree) { - this.filesSearchHandler.handleSearch(); - } - } - - /** - * Handle file modal hide - */ - onFileModalHide() { - // Clear any intermediate state if needed - } - - /** - * Populate current captures list - * @param {Array} captures - Captures data - */ - async populateCurrentCapturesList(captures) { - const currentCapturesList = document.getElementById( - "current-captures-list", - ); - const currentCapturesCount = document.querySelector( - ".current-captures-count", - ); - - if (!currentCapturesList) return; - - if (captures && captures.length > 0) { - // Normalize for generic table_rows template - const rows = captures.map((capture) => { - this.currentCaptures.set(capture.id, capture); - // Permission logic: co-owners can remove anyone's captures, contributors can only remove their own - const isOwnedByCurrentUser = capture.owner_id === this.currentUserId; - const canRemoveThisCapture = - this.permissions.canRemoveAnyAssets() || - (this.permissions.canRemoveAsset(capture) && isOwnedByCurrentUser); - - return { - css_class: !canRemoveThisCapture ? "readonly-row" : "", - data_attrs: { "capture-id": capture.id }, - cells: [ - { kind: "text", value: capture.type }, - { kind: "text", value: capture.directory }, - { - kind: "text", - value: capture.owner?.name || capture.owner?.email || "Unknown", - }, - ], - actions: canRemoveThisCapture - ? [ - { - label: "Remove", - css_class: "btn-danger", - extra_class: "mark-for-removal-btn", - data_attrs: { - "capture-id": capture.id, - "capture-type": "capture", - }, - }, - ] - : [{ html: 'N/A' }], - }; - }); - - // Render using DOMUtils - const success = await window.DOMUtils.renderTable( - currentCapturesList, - rows, - { - empty_message: "No captures in dataset", - empty_colspan: 4, - }, - ); - - if (!success) { - await window.DOMUtils.showMessage("Error loading captures", { - variant: "danger", - placement: "replace", - target: currentCapturesList, - presentation: "table", - templateContext: { colspan: 4 }, - }); - } - - if (currentCapturesCount) { - currentCapturesCount.textContent = captures.length; - } - - // Re-attach event listeners for remove buttons - this.addRemoveButtonListeners(); - } else { - currentCapturesList.innerHTML = - 'No captures in dataset'; - if (currentCapturesCount) { - currentCapturesCount.textContent = "0"; - } - } - } - - /** - * Populate current files list - * @param {Array} files - Files data - */ - async populateCurrentFilesList(files) { - // Use the existing selected-files-table from file_browser.html - const selectedFilesTable = document.getElementById("selected-files-table"); - const selectedFilesBody = selectedFilesTable?.querySelector("tbody"); - const selectedFilesDisplay = document.getElementById( - "selected-files-display", - ); - - if (!selectedFilesBody) return; - - if (files && files.length > 0) { - // Normalize for generic table_rows template - const rows = files.map((file) => { - this.currentFiles.set(file.id, file); - - // Permission logic: co-owners can remove anyone's files, contributors can only remove their own - const isOwnedByCurrentUser = file.owner_id === this.currentUserId; - const canRemoveThisFile = - this.permissions.canRemoveAnyAssets() || - (this.permissions.canRemoveAsset(file) && isOwnedByCurrentUser); - - return { - css_class: !canRemoveThisFile ? "readonly-row" : "", - data_attrs: { "file-id": file.id }, - cells: [ - { kind: "text", value: file.name }, - { kind: "text", value: file.media_type }, - { kind: "text", value: file.relative_path }, - { kind: "text", value: file.size }, - { - kind: "text", - value: file.owner?.name || file.owner?.email || "Unknown", - }, - ], - actions: canRemoveThisFile - ? [ - { - label: "Remove", - css_class: "btn-danger", - extra_class: "mark-for-removal-btn", - data_attrs: { - "file-id": file.id, - "file-type": "file", - }, - }, - ] - : [{ html: 'N/A' }], - }; - }); - - // Render using DOMUtils - const success = await window.DOMUtils.renderTable( - selectedFilesBody, - rows, - { - empty_message: "No files in dataset", - empty_colspan: 6, - }, - ); - - if (!success) { - await window.DOMUtils.showMessage("Error loading files", { - variant: "danger", - placement: "replace", - target: selectedFilesBody, - presentation: "table", - templateContext: { colspan: 6 }, - }); - } - - // Update the display input - if (selectedFilesDisplay) { - selectedFilesDisplay.value = `${files.length} file(s) selected`; - } - - // Re-attach event listeners for remove buttons - this.addRemoveButtonListeners(); - } else { - selectedFilesBody.innerHTML = - 'No files in dataset'; - if (selectedFilesDisplay) { - selectedFilesDisplay.value = "0 file(s) selected"; - } - } - } - - /** - * Load current assets from API - */ - async loadCurrentAssets() { - if (!this.datasetUuid) return; - - try { - const data = await window.APIClient.get( - `/users/dataset-details/?dataset_uuid=${this.datasetUuid}`, - ); - this.populateFromInitialData(data.captures || [], data.files || []); - } catch (error) { - console.error("Error loading current assets:", error); - } - } - - /** - * Add remove button listeners - */ - addRemoveButtonListeners() { - const removeButtons = document.querySelectorAll(".mark-for-removal-btn"); - for (const button of removeButtons) { - button.addEventListener("click", (e) => { - e.preventDefault(); - const captureId = button.dataset.captureId; - const fileId = button.dataset.fileId; - if (captureId) { - this.markCaptureForRemoval(captureId); - } else if (fileId) { - this.markFileForRemoval(fileId); - } - }); - } - } - - /** - * Sync strikethrough / checkbox on the capture search results row (step 2). - * @param {string} captureId - * @param {boolean} markedForRemoval - */ - syncCaptureSearchRowRemovalStyle(captureId, markedForRemoval) { - const searchRow = document.querySelector( - `#captures-table-body tr[data-capture-id="${captureId}"]`, - ); - if (!searchRow) return; - - if (markedForRemoval) { - searchRow.classList.add("marked-for-removal"); - const checkbox = searchRow.querySelector('input[type="checkbox"]'); - if (checkbox) { - checkbox.checked = true; - } - return; - } - - searchRow.classList.remove("marked-for-removal"); - const checkbox = searchRow.querySelector('input[type="checkbox"]'); - if (checkbox) { - const idStr = captureId.toString(); - checkbox.checked = - this.currentCaptures.has(captureId) || - this.currentCaptures.has(idStr) || - this.selectedCaptures.has(idStr); - } - } - - /** - * Mark capture for removal - * @param {string} captureId - Capture ID to mark for removal - */ - markCaptureForRemoval(captureId) { - const capture = - this.currentCaptures.get(captureId) || - this.capturesSearchHandler?.selectedCaptureDetails.get(captureId); - if (!capture) return; - - // Check if user has permission to remove this specific capture - const isOwnedByCurrentUser = capture.owner_id === this.currentUserId; - const canRemoveThisCapture = - this.permissions.canRemoveAnyAssets() || - (this.permissions.canRemoveAsset(capture) && isOwnedByCurrentUser); - - if (!canRemoveThisCapture) { - console.warn( - `User does not have permission to remove capture ${captureId}`, - ); - return; - } - - // Add to pending removals - this.pendingCaptures.set(captureId, { - action: "remove", - data: capture, - }); - - // Update visual state of current captures list - this.updateCurrentCapturesList(); - - this.syncCaptureSearchRowRemovalStyle(captureId, true); - - this.updatePendingCapturesList(); - - // Update review display - if (window.updateReviewDatasetDisplay) { - window.updateReviewDatasetDisplay(); - } - } - - /** - * Mark file for removal - * @param {string} fileId - File ID to mark for removal - */ - markFileForRemoval(fileId) { - const file = this.filesSearchHandler?.selectedFiles.get(fileId); - - if (!file) { - console.warn(`File ${fileId} not found for removal`); - return; - } - - // Check if user has permission to remove this specific file - const isOwnedByCurrentUser = file.owner_id === this.currentUserId; - const canRemoveThisFile = - this.permissions.canRemoveAnyAssets() || - (this.permissions.canRemoveAsset(file) && isOwnedByCurrentUser); - - if (!canRemoveThisFile) { - console.warn(`User does not have permission to remove file ${fileId}`); - return; - } - - // Add to pending removals - this.pendingFiles.set(fileId, { - action: "remove", - data: file, - }); - - // Update visual state of current files list - this.updateCurrentFilesList(); - - // Update review display - if (window.updateReviewDatasetDisplay) { - window.updateReviewDatasetDisplay(); - } - - // Also mark in the search results table if visible - const searchRow = document.querySelector( - `#file-tree-root li[data-file-id="${fileId}"]`, - ); - if (searchRow) { - searchRow.classList.add("marked-for-removal"); - const checkbox = searchRow.querySelector('input[type="checkbox"]'); - if (checkbox) { - checkbox.checked = true; - } - } - - this.updatePendingFilesList(); - } - - /** - * Add capture to pending additions - * @param {string} captureId - Capture ID - * @param {Object} captureData - Capture data - */ - addCaptureToPending(captureId, captureData) { - // Check if already in current captures - if (this.currentCaptures.has(captureId)) { - return; // Already in dataset - } - - // Check if already in pending additions - if ( - this.pendingCaptures.has(captureId) && - this.pendingCaptures.get(captureId).action === "add" - ) { - return; // Already marked for addition - } - - // Add to pending additions - this.pendingCaptures.set(captureId, { - action: "add", - data: captureData, - }); - - // Also add to selectedCaptures set so it shows as checked in search results - this.selectedCaptures.add(captureId.toString()); - - this.updatePendingCapturesList(); - - // Update review display - if (window.updateReviewDatasetDisplay) { - window.updateReviewDatasetDisplay(); - } - } - - /** - * Add file to pending additions - * @param {string} fileId - File ID - * @param {Object} fileData - File data - */ - addFileToPending(fileId, fileData) { - // Check if already in current files - if (this.currentFiles.has(fileId)) { - return; // Already in dataset - } - - // Check if already in pending additions - if ( - this.pendingFiles.has(fileId) && - this.pendingFiles.get(fileId).action === "add" - ) { - return; // Already marked for addition - } - - // Add to pending additions - this.pendingFiles.set(fileId, { - action: "add", - data: fileData, - }); - - this.updatePendingFilesList(); - - // Update review display - if (window.updateReviewDatasetDisplay) { - window.updateReviewDatasetDisplay(); - } - } - - /** - * Update pending captures list - */ - async updatePendingCapturesList() { - const pendingList = document.getElementById("pending-captures-list"); - const pendingCount = document.querySelector(".pending-changes-count"); - if (!pendingList) return; - - await window.DatasetPendingChanges.renderPendingTable(this, { - listElement: pendingList, - countElement: pendingCount, - entries: Array.from(this.pendingCaptures.entries()), - valueKey: "type", - entityAttr: "capture", - emptyMessage: "No pending capture changes", - }); - } - - /** - * Update pending files list - */ - async updatePendingFilesList() { - const pendingList = document.getElementById("pending-files-list"); - const pendingCount = document.querySelector(".pending-files-changes-count"); - if (!pendingList) return; - - await window.DatasetPendingChanges.renderPendingTable(this, { - listElement: pendingList, - countElement: pendingCount, - entries: Array.from(this.pendingFiles.entries()), - valueKey: "name", - entityAttr: "file", - emptyMessage: "No pending file changes", - }); - } - - /** - * Add cancel button listeners - */ - addCancelButtonListeners() { - const cancelButtons = document.querySelectorAll(".cancel-change"); - for (const button of cancelButtons) { - button.addEventListener("click", (e) => { - e.preventDefault(); - const captureId = button.dataset.captureId; - const fileId = button.dataset.fileId; - const changeType = button.dataset.changeType; - - if (changeType === "capture" && captureId) { - this.cancelCaptureChange(captureId); - } else if (changeType === "file" && fileId) { - this.cancelFileChange(fileId); - } - }); - } - } - - /** - * Cancel capture change - * @param {string} captureId - Capture ID - */ - cancelCaptureChange(captureId) { - const change = this.pendingCaptures.get(captureId); - if (!change) return; - - this.pendingCaptures.delete(captureId); - - if (change.action === "remove") { - this.updateCurrentCapturesList(); - this.syncCaptureSearchRowRemovalStyle(captureId, false); - } else if (change.action === "add") { - this.selectedCaptures.delete(captureId.toString()); - this.syncCaptureSearchRowRemovalStyle(captureId, false); - } - - this.updatePendingCapturesList(); - - // Update review display - if (window.updateReviewDatasetDisplay) { - window.updateReviewDatasetDisplay(); - } - } - - /** - * Cancel file change - * @param {string} fileId - File ID - */ - cancelFileChange(fileId) { - const change = this.pendingFiles.get(fileId); - if (!change) return; - - this.pendingFiles.delete(fileId); - - if (change.action === "remove") { - // Update visual state of current files list - this.updateCurrentFilesList(); - } else if (change.action === "add") { - // Remove from SearchHandler's selectedFiles if it exists - if (this.filesSearchHandler) { - this.filesSearchHandler.selectedFiles.delete(fileId); - } - } - - this.updatePendingFilesList(); - - // Update review display - if (window.updateReviewDatasetDisplay) { - window.updateReviewDatasetDisplay(); - } - } - - /** - * Get pending changes - * @returns {Object} Pending changes object - */ - getPendingChanges() { - return { - captures: Array.from(this.pendingCaptures.entries()), - files: Array.from(this.pendingFiles.entries()), - }; - } - - /** - * Check if there are any pending changes - * @returns {boolean} Whether there are pending changes - */ - hasChanges() { - return this.pendingCaptures.size > 0 || this.pendingFiles.size > 0; - } - - /** - * Handle file removal (override for edit mode) - * @param {string} fileId - File ID to remove - */ - handleFileRemoval(fileId) { - // In edit mode: mark for removal instead of actually removing - this.markFileForRemoval(fileId); - } - - /** - * Handle capture removal (override for edit mode) - * @param {string} captureId - Capture ID to remove - */ - handleCaptureRemoval(captureId) { - // In edit mode: mark for removal instead of actually removing - this.markCaptureForRemoval(captureId); - } - - /** - * Handle remove all files (override for edit mode) - */ - handleRemoveAllFiles() { - // In edit mode: mark files for removal only if user has permission - if (this.filesSearchHandler?.selectedFiles) { - let removedCount = 0; - for (const [ - fileId, - file, - ] of this.filesSearchHandler.selectedFiles.entries()) { - // Only mark for removal if user has permission - if (this.permissions.canRemoveAsset(file)) { - this.markFileForRemoval(fileId); - removedCount++; - } - } - - // Disable the remove all files button if any files were marked for removal - if (removedCount > 0) { - const removeAllFilesButton = document.querySelector( - ".remove-all-selected-files-button", - ); - if (removeAllFilesButton) { - removeAllFilesButton.disabled = true; - removeAllFilesButton.classList.add("disabled-element"); - } - } - } - } - - /** - * Update current files list visual state - * This method only updates the visual state of existing files (e.g., marking for removal) - * It does NOT add new files - those should only appear in pending changes - */ - updateCurrentFilesList() { - const selectedFilesTable = document.getElementById("selected-files-table"); - const selectedFilesBody = selectedFilesTable?.querySelector("tbody"); - if (!selectedFilesBody) return; - - // Update visual state of existing rows based on pending changes - const rows = selectedFilesBody.querySelectorAll("tr[data-file-id]"); - for (const row of rows) { - const fileId = row.dataset.fileId; - const pendingChange = this.pendingFiles.get(fileId); - - if (pendingChange && pendingChange.action === "remove") { - // Mark as pending removal - row.classList.add("marked-for-removal"); - const removeButton = row.querySelector(".mark-for-removal-btn"); - if (removeButton) { - removeButton.disabled = true; - removeButton.classList.add("disabled-element"); - } - } else { - // Restore normal state - row.classList.remove("marked-for-removal"); - const removeButton = row.querySelector(".mark-for-removal-btn"); - if (removeButton) { - removeButton.disabled = false; - removeButton.classList.remove("disabled-element"); - } - } - } - } - - /** - * Update current captures list visual state - * This method only updates the visual state of existing captures (e.g., marking for removal) - * It does NOT add new captures - those should only appear in pending changes - */ - updateCurrentCapturesList() { - const currentCapturesList = document.getElementById( - "current-captures-list", - ); - if (!currentCapturesList) return; - - // Update visual state of existing rows based on pending changes - const rows = currentCapturesList.querySelectorAll("tr[data-capture-id]"); - for (const row of rows) { - const captureId = row.dataset.captureId; - const pendingChange = this.pendingCaptures.get(captureId); - - if (pendingChange && pendingChange.action === "remove") { - // Mark as pending removal - row.classList.add("marked-for-removal"); - const removeButton = row.querySelector(".mark-for-removal-btn"); - if (removeButton) { - removeButton.disabled = true; - removeButton.classList.add("disabled-element"); - } - } else { - // Restore normal state - row.classList.remove("marked-for-removal"); - const removeButton = row.querySelector(".mark-for-removal-btn"); - if (removeButton) { - removeButton.disabled = false; - removeButton.classList.remove("disabled-element"); - } - } - } - } - - /** - * Update hidden fields (no-op for editing mode) - */ - updateHiddenFields() { - // This method is called by SearchHandler but not needed for editing mode - // We'll implement it as a no-op since editing mode doesn't use hidden fields - } - - /** - * Handle form submission for edit mode - * @param {Event} e - Submit event - */ - handleSubmit(e) { - e.preventDefault(); - - // Collect form data - const formData = new FormData(document.getElementById("datasetForm")); - - // Add pending changes to form data - const pendingChanges = this.getPendingChanges(); - - // Add pending captures - const capturesAdd = []; - const capturesRemove = []; - for (const [id, change] of pendingChanges.captures) { - if (change.action === "add") { - capturesAdd.push(id); - } else if (change.action === "remove") { - capturesRemove.push(id); - } - } - - // Add pending files - const filesAdd = []; - const filesRemove = []; - for (const [id, change] of pendingChanges.files) { - if (change.action === "add") { - filesAdd.push(id); - } else if (change.action === "remove") { - filesRemove.push(id); - } - } - - // Add comma-separated lists to form data - if (capturesAdd.length > 0) { - formData.append("captures_add", capturesAdd.join(",")); - } - if (capturesRemove.length > 0) { - formData.append("captures_remove", capturesRemove.join(",")); - } - if (filesAdd.length > 0) { - formData.append("files_add", filesAdd.join(",")); - } - if (filesRemove.length > 0) { - formData.append("files_remove", filesRemove.join(",")); - } - - // Add author changes if they exist - if ( - this.authorChanges && - (this.authorChanges.added.length > 0 || - this.authorChanges.removed.length > 0 || - Object.keys(this.authorChanges.modified).length > 0) - ) { - formData.append("author_changes", JSON.stringify(this.authorChanges)); - } - - // Submit the form - this.submitForm(formData); - } - - /** - * Submit the form with pending changes - * @param {FormData} formData - Form data to submit - */ - async submitForm(formData) { - try { - // Show loading state - const submitBtn = document.getElementById("submitForm"); - if (submitBtn) { - submitBtn.disabled = true; - submitBtn.innerHTML = - 'Updating...'; - } - - // Submit form - const response = await fetch(window.location.href, { - method: "POST", - body: formData, - headers: { - "X-CSRFToken": document.querySelector("[name=csrfmiddlewaretoken]") - .value, - }, - }); - - if (response.ok) { - // Success - redirect or show success message - const result = await response.json(); - if (result.success) { - // Redirect to dataset list or show success message - window.location.href = result.redirect_url || "/users/dataset-list/"; - } else { - // Show error message - this.showToast( - result.message || "An error occurred while updating the dataset.", - "error", - ); - } - } else { - // Handle error response - this.showToast( - "An error occurred while updating the dataset.", - "error", - ); - } - } catch (error) { - console.error("Error submitting form:", error); - this.showToast( - "An error occurred while updating the dataset.", - "error", - ); - } finally { - // Restore submit button - const submitBtn = document.getElementById("submitForm"); - if (submitBtn) { - submitBtn.disabled = false; - submitBtn.innerHTML = "Update Dataset"; - } - } - } - - /** - * Initialize authors management for edit mode - */ - initializeAuthorsManagement() { - window.DatasetAuthorsUI?.mount(this, { - mode: "edit", - initialAuthors: this.initialAuthors, - }); - } - - /** - * Update dataset authors with pending changes (for review display) - */ - async updateDatasetAuthors(authorsField) { - const authorsElement = document.querySelector(".dataset-authors"); - if (!authorsElement) return; - - if (!authorsField) { - // In contributor view, there's no editable authors field, so show original authors - const originalAuthors = - window.datasetModeManager?.originalDatasetData?.authors || []; - const originalAuthorNames = this.formatAuthors(originalAuthors); - authorsElement.textContent = originalAuthorNames; - return; - } - - try { - // Get current authors with DOM-based stable IDs - const currentAuthorsWithIds = this.getCurrentAuthorsWithDOMIds(); - // Get original authors from DatasetModeManager's captured data - const originalAuthors = - window.datasetModeManager?.originalDatasetData?.authors || []; - - // Format original authors for display - const originalAuthorNames = this.formatAuthors(originalAuthors); - - // Always show original value - authorsElement.innerHTML = `${originalAuthorNames}`; - - // Calculate changes using DOM-based IDs - const changes = this.calculateAuthorChanges( - originalAuthors, - currentAuthorsWithIds, - ); - - // If there are changes, request server-side rendering - if (changes.length > 0) { - try { - // Normalize for generic change_list template - const normalizedChanges = changes.map((change) => { - if (change.type === "add") { - return { - type: "add", - parts: [ - { text: "Add: " }, - { text: change.name, css_class: "text-success" }, - ], - }; - } - if (change.type === "remove") { - return { - type: "remove", - parts: [ - { text: "Remove: " }, - { text: change.name, css_class: "text-danger" }, - ], - }; - } - if (change.type === "change") { - // Handle name changes - if ( - change.oldName !== undefined && - change.newName !== undefined - ) { - return { - type: "change", - parts: [ - { text: 'Change Name: "' }, - { text: change.oldName }, - { text: '" → ' }, - { text: `"${change.newName}"`, css_class: "text-warning" }, - ], - }; - } - // Handle ORCID changes - if ( - change.oldOrcid !== undefined && - change.newOrcid !== undefined - ) { - const oldOrcidDisplay = change.oldOrcid || ""; - const newOrcidDisplay = change.newOrcid || ""; - return { - type: "change", - parts: [ - { text: 'Change ORCID ID: "' }, - { text: oldOrcidDisplay }, - { text: '" → ' }, - { text: `"${newOrcidDisplay}"`, css_class: "text-warning" }, - ], - }; - } - } - return change; - }); - - // Request server to render using generic change_list - const response = await window.APIClient.post( - "/users/render-html/", - { - template: "users/components/change_list.html", - context: { changes: normalizedChanges }, - }, - null, - true, - ); // true = send as JSON - - // Insert the server-rendered HTML - if (response.html) { - authorsElement.insertAdjacentHTML("beforeend", response.html); - } - } catch (error) { - console.error("Error rendering author changes:", error); - // Fallback: show error message - authorsElement.insertAdjacentHTML( - "beforeend", - '
    Error loading changes
    ', - ); - } - } - } catch (e) { - console.error("Error in updateDatasetAuthors:", e); - authorsElement.innerHTML = - 'Error parsing authors.'; - } - } - - /** - * Get current authors with DOM-based stable IDs - */ - getCurrentAuthorsWithDOMIds() { - return window.AuthorsManager.getCurrentAuthorsWithDOMIds(); - } - - /** - * Capture authors with DOM-based stable IDs - */ - captureAuthorsWithDOMIds(authors) { - const authorsList = document.querySelector(".authors-list"); - const authorsWithIds = []; - - if (authorsList) { - // Get author items from DOM - const authorItems = authorsList.querySelectorAll(".author-item"); - - for (const [index, authorItem] of authorItems.entries()) { - // Get or create a stable ID for this author item - const authorId = authorItem.id; - if (!authorId) { - console.error("❌ Author item missing ID"); - return; - } - - // Get the author data (either from the authors array or from DOM inputs) - let authorData; - if (authors[index]) { - authorData = - typeof authors[index] === "string" - ? { name: authors[index], orcid_id: "" } - : { ...authors[index] }; - } else { - // Fallback to DOM inputs if author data is missing - const nameInput = authorItem.querySelector(".author-name-input"); - const orcidInput = authorItem.querySelector(".author-orcid-input"); - authorData = { - name: nameInput?.value || "", - orcid_id: orcidInput?.value || "", - }; - } - - // Add the stable ID - authorData._stableId = authorId; - authorsWithIds.push(authorData); - } - } - - return authorsWithIds; - } - - /** - * Format authors array into display string - */ - formatAuthors(authors) { - return window.AuthorsManager.formatAuthors(authors); - } - - /** - * Calculate author changes between original and current - */ - calculateAuthorChanges(originalAuthors, currentAuthors) { - const changes = []; - - // Create maps using stable IDs - const originalMap = new Map(); - const currentMap = new Map(); - - for (const author of originalAuthors) { - if (author._stableId) { - originalMap.set(author._stableId, author); - } - } - - for (const author of currentAuthors) { - if (author._stableId) { - currentMap.set(author._stableId, author); - } - } - - // Find additions (in current but not in original) - for (const [id, author] of currentMap) { - if (!originalMap.has(id)) { - const name = author.name || "Unknown"; - changes.push({ type: "add", name }); - } - } - - // Find removals (in original but not in current) - for (const [id, author] of originalMap) { - if (!currentMap.has(id)) { - const name = author.name || "Unknown"; - changes.push({ type: "remove", name }); - } - } - - // Find changes (same ID but different content) - for (const [id, currentAuthor] of currentMap) { - const originalAuthor = originalMap.get(id); - if (originalAuthor) { - const currentName = currentAuthor.name || "Unknown"; - const originalName = originalAuthor.name || "Unknown"; - - const currentOrcid = currentAuthor.orcid_id || ""; - const originalOrcid = originalAuthor.orcid_id || ""; - - if (currentName !== originalName) { - changes.push({ - type: "change", - oldName: originalName, - newName: currentName, - }); - } - if (currentOrcid !== originalOrcid) { - changes.push({ - type: "change", - oldOrcid: originalOrcid, - newOrcid: currentOrcid, - }); - } - } - } - - return changes; - } + /** + * Initialize dataset editing handler + * @param {Object} config - Configuration object + */ + constructor(config) { + super() + this.datasetUuid = config.datasetUuid + this.permissions = config.permissions // PermissionsManager instance + this.currentUserId = config.currentUserId + + // Current assets in dataset + this.currentCaptures = new Map() + this.currentFiles = new Map() + + // Pending changes + this.pendingCaptures = new Map() // key: captureId, value: {action: 'add'|'remove', data: {...}} + this.pendingFiles = new Map() // key: fileId, value: {action: 'add'|'remove', data: {...}} + + // Search handlers + this.capturesSearchHandler = null + this.filesSearchHandler = null + + // Properties that SearchHandler expects from formHandler + this.selectedCaptures = new Set() + this.selectedFiles = new Set() + + // Store initial data + this.initialCaptures = config.initialCaptures || [] + this.initialFiles = config.initialFiles || [] + + this.initializeEventListeners() + this.initializeAuthorsManagement() + + // Load current assets if no initial data provided + if (!this.initialCaptures.length && !this.initialFiles.length) { + this.loadCurrentAssets() + } + } + + /** + * Initialize event listeners + */ + initializeEventListeners() { + // Initialize search handlers if they exist + if (window.AssetSearchHandler) { + this.capturesSearchHandler = new window.AssetSearchHandler({ + searchFormId: "captures-search-form", + searchButtonId: "search-captures", + clearButtonId: "clear-captures-search", + tableBodyId: "captures-table-body", + paginationContainerId: "captures-pagination", + type: "captures", + formHandler: this, + isEditMode: true, + }) + + this.filesSearchHandler = new window.AssetSearchHandler({ + searchFormId: "files-search-form", + searchButtonId: "search-files", + clearButtonId: "clear-files-search", + tableBodyId: "file-tree-root", + paginationContainerId: "files-pagination", + confirmFileSelectionId: "confirm-file-selection", + type: "files", + formHandler: this, + isEditMode: true, + apiEndpoint: window.location.pathname, + }) + + // Initialize captures search to show initial state + if ( + this.capturesSearchHandler && + typeof this.capturesSearchHandler.initializeCapturesSearch === + "function" + ) { + this.capturesSearchHandler.initializeCapturesSearch() + } + + // Populate initial data now that handlers are ready + this.populateFromInitialData( + this.initialCaptures, + this.initialFiles, + ) + } + + const datasetForm = document.getElementById("datasetForm") + if (datasetForm && !datasetForm.dataset.enterSubmitGuardBound) { + datasetForm.dataset.enterSubmitGuardBound = "true" + datasetForm.addEventListener("submit", (e) => { + e.preventDefault() + }) + datasetForm.addEventListener("keypress", (e) => { + if (e.key === "Enter") { + e.preventDefault() + } + }) + } + } + + /** + * Set search handler reference + * @param {Object} searchHandler - Search handler instance + * @param {string} type - Handler type (captures or files) + */ + setSearchHandler(searchHandler, type) { + if (type === "captures") { + this.capturesSearchHandler = searchHandler + // Defer population until SearchHandler is fully ready + Promise.resolve().then(() => { + this.populateSearchHandlerWithInitialData() + }) + } else if (type === "files") { + this.filesSearchHandler = searchHandler + this.filesSearchHandler.updateSelectedFilesList() + } + + // If we have initial data and both handlers are ready, populate the data + if ( + this.capturesSearchHandler && + this.filesSearchHandler && + (this.initialCaptures.length > 0 || this.initialFiles.length > 0) + ) { + this.populateFromInitialData( + this.initialCaptures, + this.initialFiles, + ) + } + } + + /** + * Populate search handler with initial data + */ + populateSearchHandlerWithInitialData() { + // Populate the SearchHandler with initial captures if available + if ( + this.capturesSearchHandler?.selectedCaptures && + this.capturesSearchHandler.selectedCaptureDetails && + this.initialCaptures && + this.initialCaptures.length > 0 + ) { + for (const capture of this.initialCaptures) { + this.capturesSearchHandler.selectedCaptures.add( + capture.id.toString(), + ) + this.capturesSearchHandler.selectedCaptureDetails.set( + capture.id.toString(), + capture, + ) + } + } + // Also populate the DatasetEditingHandler's selectedCaptures set + if (this.initialCaptures && this.initialCaptures.length > 0) { + for (const capture of this.initialCaptures) { + this.selectedCaptures.add(capture.id.toString()) + } + } + } + + /** + * Populate from initial data + * @param {Array} initialCaptures - Initial captures data + * @param {Array} initialFiles - Initial files data + */ + populateFromInitialData(initialCaptures, initialFiles) { + // Populate current captures in the side panel table + this.currentCaptures.clear() + this.populateCurrentCapturesList(initialCaptures) + + // Use the existing SearchHandler to populate captures in the main table + if (this.capturesSearchHandler?.selectedCaptures) { + if (initialCaptures && initialCaptures.length > 0) { + for (const capture of initialCaptures) { + this.capturesSearchHandler.selectedCaptures.add( + capture.id.toString(), + ) + this.capturesSearchHandler.selectedCaptureDetails.set( + capture.id.toString(), + capture, + ) + } + } + } + + // Also populate the DatasetEditingHandler's selectedCaptures set + if (initialCaptures && initialCaptures.length > 0) { + for (const captureId of initialCaptures) { + this.selectedCaptures.add(captureId.toString()) + } + } + + // Populate current files + this.currentFiles.clear() + this.populateCurrentFilesList(initialFiles) + + // Use the existing SearchHandler to populate files for the file browser + if (this.filesSearchHandler) { + if (initialFiles && initialFiles.length > 0) { + for (const file of initialFiles) { + this.filesSearchHandler.selectedFiles.set(file.id, file) + } + } + this.filesSearchHandler.updateSelectedFilesList() + } + + // Add event listeners for remove buttons + this.addRemoveButtonListeners() + + // Initialize file browser modal handlers + this.initializeFileBrowserModal() + } + + /** + * Initialize file browser modal handlers + */ + initializeFileBrowserModal() { + window.AuthorsManager?.bindFileTreeModalHandlers(this) + } + + /** + * Handle file modal show + */ + onFileModalShow() { + // Trigger initial file tree loading if filesSearchHandler exists and tree hasn't been loaded + if (this.filesSearchHandler && !this.filesSearchHandler.currentTree) { + this.filesSearchHandler.handleSearch() + } + } + + /** + * Handle file modal hide + */ + onFileModalHide() { + // Clear any intermediate state if needed + } + + /** + * Populate current captures list + * @param {Array} captures - Captures data + */ + async populateCurrentCapturesList(captures) { + const currentCapturesList = document.getElementById( + "current-captures-list", + ) + const currentCapturesCount = document.querySelector( + ".current-captures-count", + ) + + if (!currentCapturesList) return + + if (captures && captures.length > 0) { + // Normalize for generic table_rows template + const rows = captures.map((capture) => { + this.currentCaptures.set(capture.id, capture) + // Permission logic: co-owners can remove anyone's captures, contributors can only remove their own + const isOwnedByCurrentUser = + capture.owner_id === this.currentUserId + const canRemoveThisCapture = + this.permissions.canRemoveAnyAssets() || + (this.permissions.canRemoveAsset(capture) && + isOwnedByCurrentUser) + + return { + css_class: !canRemoveThisCapture ? "readonly-row" : "", + data_attrs: { "capture-id": capture.id }, + cells: [ + { kind: "text", value: capture.type }, + { kind: "text", value: capture.directory }, + { + kind: "text", + value: + capture.owner?.name || + capture.owner?.email || + "Unknown", + }, + ], + actions: canRemoveThisCapture + ? [ + { + label: "Remove", + css_class: "btn-danger", + extra_class: "mark-for-removal-btn", + data_attrs: { + "capture-id": capture.id, + "capture-type": "capture", + }, + }, + ] + : [{ html: 'N/A' }], + } + }) + + // Render using DOMUtils + const success = await window.DOMUtils.renderTable( + currentCapturesList, + rows, + { + empty_message: "No captures in dataset", + empty_colspan: 4, + }, + ) + + if (!success) { + await window.DOMUtils.showMessage("Error loading captures", { + variant: "danger", + placement: "replace", + target: currentCapturesList, + presentation: "table", + templateContext: { colspan: 4 }, + }) + } + + if (currentCapturesCount) { + currentCapturesCount.textContent = captures.length + } + + // Re-attach event listeners for remove buttons + this.addRemoveButtonListeners() + } else { + currentCapturesList.innerHTML = + 'No captures in dataset' + if (currentCapturesCount) { + currentCapturesCount.textContent = "0" + } + } + } + + /** + * Populate current files list + * @param {Array} files - Files data + */ + async populateCurrentFilesList(files) { + // Use the existing selected-files-table from file_browser.html + const selectedFilesTable = document.getElementById( + "selected-files-table", + ) + const selectedFilesBody = selectedFilesTable?.querySelector("tbody") + const selectedFilesDisplay = document.getElementById( + "selected-files-display", + ) + + if (!selectedFilesBody) return + + if (files && files.length > 0) { + // Normalize for generic table_rows template + const rows = files.map((file) => { + this.currentFiles.set(file.id, file) + + // Permission logic: co-owners can remove anyone's files, contributors can only remove their own + const isOwnedByCurrentUser = + file.owner_id === this.currentUserId + const canRemoveThisFile = + this.permissions.canRemoveAnyAssets() || + (this.permissions.canRemoveAsset(file) && + isOwnedByCurrentUser) + + return { + css_class: !canRemoveThisFile ? "readonly-row" : "", + data_attrs: { "file-id": file.id }, + cells: [ + { kind: "text", value: file.name }, + { kind: "text", value: file.media_type }, + { kind: "text", value: file.relative_path }, + { kind: "text", value: file.size }, + { + kind: "text", + value: + file.owner?.name || + file.owner?.email || + "Unknown", + }, + ], + actions: canRemoveThisFile + ? [ + { + label: "Remove", + css_class: "btn-danger", + extra_class: "mark-for-removal-btn", + data_attrs: { + "file-id": file.id, + "file-type": "file", + }, + }, + ] + : [{ html: 'N/A' }], + } + }) + + // Render using DOMUtils + const success = await window.DOMUtils.renderTable( + selectedFilesBody, + rows, + { + empty_message: "No files in dataset", + empty_colspan: 6, + }, + ) + + if (!success) { + await window.DOMUtils.showMessage("Error loading files", { + variant: "danger", + placement: "replace", + target: selectedFilesBody, + presentation: "table", + templateContext: { colspan: 6 }, + }) + } + + // Update the display input + if (selectedFilesDisplay) { + selectedFilesDisplay.value = `${files.length} file(s) selected` + } + + // Re-attach event listeners for remove buttons + this.addRemoveButtonListeners() + } else { + selectedFilesBody.innerHTML = + 'No files in dataset' + if (selectedFilesDisplay) { + selectedFilesDisplay.value = "0 file(s) selected" + } + } + } + + /** + * Load current assets from API + */ + async loadCurrentAssets() { + if (!this.datasetUuid) return + + try { + const data = await window.APIClient.get( + `/users/dataset-details/?dataset_uuid=${this.datasetUuid}`, + ) + this.populateFromInitialData(data.captures || [], data.files || []) + } catch (error) { + console.error("Error loading current assets:", error) + } + } + + /** + * Add remove button listeners + */ + addRemoveButtonListeners() { + const removeButtons = document.querySelectorAll(".mark-for-removal-btn") + for (const button of removeButtons) { + button.addEventListener("click", (e) => { + e.preventDefault() + const captureId = button.dataset.captureId + const fileId = button.dataset.fileId + if (captureId) { + this.markCaptureForRemoval(captureId) + } else if (fileId) { + this.markFileForRemoval(fileId) + } + }) + } + } + + /** + * Sync strikethrough / checkbox on the capture search results row (step 2). + * @param {string} captureId + * @param {boolean} markedForRemoval + */ + syncCaptureSearchRowRemovalStyle(captureId, markedForRemoval) { + const searchRow = document.querySelector( + `#captures-table-body tr[data-capture-id="${captureId}"]`, + ) + if (!searchRow) return + + if (markedForRemoval) { + searchRow.classList.add("marked-for-removal") + const checkbox = searchRow.querySelector('input[type="checkbox"]') + if (checkbox) { + checkbox.checked = true + } + return + } + + searchRow.classList.remove("marked-for-removal") + const checkbox = searchRow.querySelector('input[type="checkbox"]') + if (checkbox) { + const idStr = captureId.toString() + checkbox.checked = + this.currentCaptures.has(captureId) || + this.currentCaptures.has(idStr) || + this.selectedCaptures.has(idStr) + } + } + + /** + * Mark capture for removal + * @param {string} captureId - Capture ID to mark for removal + */ + markCaptureForRemoval(captureId) { + const capture = + this.currentCaptures.get(captureId) || + this.capturesSearchHandler?.selectedCaptureDetails.get(captureId) + if (!capture) return + + // Check if user has permission to remove this specific capture + const isOwnedByCurrentUser = capture.owner_id === this.currentUserId + const canRemoveThisCapture = + this.permissions.canRemoveAnyAssets() || + (this.permissions.canRemoveAsset(capture) && isOwnedByCurrentUser) + + if (!canRemoveThisCapture) { + console.warn( + `User does not have permission to remove capture ${captureId}`, + ) + return + } + + // Add to pending removals + this.pendingCaptures.set(captureId, { + action: "remove", + data: capture, + }) + + // Update visual state of current captures list + this.updateCurrentCapturesList() + + this.syncCaptureSearchRowRemovalStyle(captureId, true) + + this.updatePendingCapturesList() + + // Update review display + if (window.updateReviewDatasetDisplay) { + window.updateReviewDatasetDisplay() + } + } + + /** + * Mark file for removal + * @param {string} fileId - File ID to mark for removal + */ + markFileForRemoval(fileId) { + const file = this.filesSearchHandler?.selectedFiles.get(fileId) + + if (!file) { + console.warn(`File ${fileId} not found for removal`) + return + } + + // Check if user has permission to remove this specific file + const isOwnedByCurrentUser = file.owner_id === this.currentUserId + const canRemoveThisFile = + this.permissions.canRemoveAnyAssets() || + (this.permissions.canRemoveAsset(file) && isOwnedByCurrentUser) + + if (!canRemoveThisFile) { + console.warn( + `User does not have permission to remove file ${fileId}`, + ) + return + } + + // Add to pending removals + this.pendingFiles.set(fileId, { + action: "remove", + data: file, + }) + + // Update visual state of current files list + this.updateCurrentFilesList() + + // Update review display + if (window.updateReviewDatasetDisplay) { + window.updateReviewDatasetDisplay() + } + + // Also mark in the search results table if visible + const searchRow = document.querySelector( + `#file-tree-root li[data-file-id="${fileId}"]`, + ) + if (searchRow) { + searchRow.classList.add("marked-for-removal") + const checkbox = searchRow.querySelector('input[type="checkbox"]') + if (checkbox) { + checkbox.checked = true + } + } + + this.updatePendingFilesList() + } + + /** + * Add capture to pending additions + * @param {string} captureId - Capture ID + * @param {Object} captureData - Capture data + */ + addCaptureToPending(captureId, captureData) { + // Check if already in current captures + if (this.currentCaptures.has(captureId)) { + return // Already in dataset + } + + // Check if already in pending additions + if ( + this.pendingCaptures.has(captureId) && + this.pendingCaptures.get(captureId).action === "add" + ) { + return // Already marked for addition + } + + // Add to pending additions + this.pendingCaptures.set(captureId, { + action: "add", + data: captureData, + }) + + // Also add to selectedCaptures set so it shows as checked in search results + this.selectedCaptures.add(captureId.toString()) + + this.updatePendingCapturesList() + + // Update review display + if (window.updateReviewDatasetDisplay) { + window.updateReviewDatasetDisplay() + } + } + + /** + * Add file to pending additions + * @param {string} fileId - File ID + * @param {Object} fileData - File data + */ + addFileToPending(fileId, fileData) { + // Check if already in current files + if (this.currentFiles.has(fileId)) { + return // Already in dataset + } + + // Check if already in pending additions + if ( + this.pendingFiles.has(fileId) && + this.pendingFiles.get(fileId).action === "add" + ) { + return // Already marked for addition + } + + // Add to pending additions + this.pendingFiles.set(fileId, { + action: "add", + data: fileData, + }) + + this.updatePendingFilesList() + + // Update review display + if (window.updateReviewDatasetDisplay) { + window.updateReviewDatasetDisplay() + } + } + + /** + * Update pending captures list + */ + async updatePendingCapturesList() { + const pendingList = document.getElementById("pending-captures-list") + const pendingCount = document.querySelector(".pending-changes-count") + if (!pendingList) return + + await window.DatasetPendingChanges.renderPendingTable(this, { + listElement: pendingList, + countElement: pendingCount, + entries: Array.from(this.pendingCaptures.entries()), + valueKey: "type", + entityAttr: "capture", + emptyMessage: "No pending capture changes", + }) + } + + /** + * Update pending files list + */ + async updatePendingFilesList() { + const pendingList = document.getElementById("pending-files-list") + const pendingCount = document.querySelector( + ".pending-files-changes-count", + ) + if (!pendingList) return + + await window.DatasetPendingChanges.renderPendingTable(this, { + listElement: pendingList, + countElement: pendingCount, + entries: Array.from(this.pendingFiles.entries()), + valueKey: "name", + entityAttr: "file", + emptyMessage: "No pending file changes", + }) + } + + /** + * Add cancel button listeners + */ + addCancelButtonListeners() { + const cancelButtons = document.querySelectorAll(".cancel-change") + for (const button of cancelButtons) { + button.addEventListener("click", (e) => { + e.preventDefault() + const captureId = button.dataset.captureId + const fileId = button.dataset.fileId + const changeType = button.dataset.changeType + + if (changeType === "capture" && captureId) { + this.cancelCaptureChange(captureId) + } else if (changeType === "file" && fileId) { + this.cancelFileChange(fileId) + } + }) + } + } + + /** + * Cancel capture change + * @param {string} captureId - Capture ID + */ + cancelCaptureChange(captureId) { + const change = this.pendingCaptures.get(captureId) + if (!change) return + + this.pendingCaptures.delete(captureId) + + if (change.action === "remove") { + this.updateCurrentCapturesList() + this.syncCaptureSearchRowRemovalStyle(captureId, false) + } else if (change.action === "add") { + this.selectedCaptures.delete(captureId.toString()) + this.syncCaptureSearchRowRemovalStyle(captureId, false) + } + + this.updatePendingCapturesList() + + // Update review display + if (window.updateReviewDatasetDisplay) { + window.updateReviewDatasetDisplay() + } + } + + /** + * Cancel file change + * @param {string} fileId - File ID + */ + cancelFileChange(fileId) { + const change = this.pendingFiles.get(fileId) + if (!change) return + + this.pendingFiles.delete(fileId) + + if (change.action === "remove") { + // Update visual state of current files list + this.updateCurrentFilesList() + } else if (change.action === "add") { + // Remove from SearchHandler's selectedFiles if it exists + if (this.filesSearchHandler) { + this.filesSearchHandler.selectedFiles.delete(fileId) + } + } + + this.updatePendingFilesList() + + // Update review display + if (window.updateReviewDatasetDisplay) { + window.updateReviewDatasetDisplay() + } + } + + /** + * Get pending changes + * @returns {Object} Pending changes object + */ + getPendingChanges() { + return { + captures: Array.from(this.pendingCaptures.entries()), + files: Array.from(this.pendingFiles.entries()), + } + } + + /** + * Check if there are any pending changes + * @returns {boolean} Whether there are pending changes + */ + hasChanges() { + return this.pendingCaptures.size > 0 || this.pendingFiles.size > 0 + } + + /** + * Handle file removal (override for edit mode) + * @param {string} fileId - File ID to remove + */ + handleFileRemoval(fileId) { + // In edit mode: mark for removal instead of actually removing + this.markFileForRemoval(fileId) + } + + /** + * Handle capture removal (override for edit mode) + * @param {string} captureId - Capture ID to remove + */ + handleCaptureRemoval(captureId) { + // In edit mode: mark for removal instead of actually removing + this.markCaptureForRemoval(captureId) + } + + /** + * Handle remove all files (override for edit mode) + */ + handleRemoveAllFiles() { + // In edit mode: mark files for removal only if user has permission + if (this.filesSearchHandler?.selectedFiles) { + let removedCount = 0 + for (const [ + fileId, + file, + ] of this.filesSearchHandler.selectedFiles.entries()) { + // Only mark for removal if user has permission + if (this.permissions.canRemoveAsset(file)) { + this.markFileForRemoval(fileId) + removedCount++ + } + } + + // Disable the remove all files button if any files were marked for removal + if (removedCount > 0) { + const removeAllFilesButton = document.querySelector( + ".remove-all-selected-files-button", + ) + if (removeAllFilesButton) { + removeAllFilesButton.disabled = true + removeAllFilesButton.classList.add("disabled-element") + } + } + } + } + + /** + * Update current files list visual state + * This method only updates the visual state of existing files (e.g., marking for removal) + * It does NOT add new files - those should only appear in pending changes + */ + updateCurrentFilesList() { + const selectedFilesTable = document.getElementById( + "selected-files-table", + ) + const selectedFilesBody = selectedFilesTable?.querySelector("tbody") + if (!selectedFilesBody) return + + // Update visual state of existing rows based on pending changes + const rows = selectedFilesBody.querySelectorAll("tr[data-file-id]") + for (const row of rows) { + const fileId = row.dataset.fileId + const pendingChange = this.pendingFiles.get(fileId) + + if (pendingChange && pendingChange.action === "remove") { + // Mark as pending removal + row.classList.add("marked-for-removal") + const removeButton = row.querySelector(".mark-for-removal-btn") + if (removeButton) { + removeButton.disabled = true + removeButton.classList.add("disabled-element") + } + } else { + // Restore normal state + row.classList.remove("marked-for-removal") + const removeButton = row.querySelector(".mark-for-removal-btn") + if (removeButton) { + removeButton.disabled = false + removeButton.classList.remove("disabled-element") + } + } + } + } + + /** + * Update current captures list visual state + * This method only updates the visual state of existing captures (e.g., marking for removal) + * It does NOT add new captures - those should only appear in pending changes + */ + updateCurrentCapturesList() { + const currentCapturesList = document.getElementById( + "current-captures-list", + ) + if (!currentCapturesList) return + + // Update visual state of existing rows based on pending changes + const rows = currentCapturesList.querySelectorAll("tr[data-capture-id]") + for (const row of rows) { + const captureId = row.dataset.captureId + const pendingChange = this.pendingCaptures.get(captureId) + + if (pendingChange && pendingChange.action === "remove") { + // Mark as pending removal + row.classList.add("marked-for-removal") + const removeButton = row.querySelector(".mark-for-removal-btn") + if (removeButton) { + removeButton.disabled = true + removeButton.classList.add("disabled-element") + } + } else { + // Restore normal state + row.classList.remove("marked-for-removal") + const removeButton = row.querySelector(".mark-for-removal-btn") + if (removeButton) { + removeButton.disabled = false + removeButton.classList.remove("disabled-element") + } + } + } + } + + /** + * Update hidden fields (no-op for editing mode) + */ + updateHiddenFields() { + // This method is called by SearchHandler but not needed for editing mode + // We'll implement it as a no-op since editing mode doesn't use hidden fields + } + + /** + * Handle form submission for edit mode + * @param {Event} e - Submit event + */ + handleSubmit(e) { + e.preventDefault() + + // Collect form data + const formData = new FormData(document.getElementById("datasetForm")) + + // Add pending changes to form data + const pendingChanges = this.getPendingChanges() + + // Add pending captures + const capturesAdd = [] + const capturesRemove = [] + for (const [id, change] of pendingChanges.captures) { + if (change.action === "add") { + capturesAdd.push(id) + } else if (change.action === "remove") { + capturesRemove.push(id) + } + } + + // Add pending files + const filesAdd = [] + const filesRemove = [] + for (const [id, change] of pendingChanges.files) { + if (change.action === "add") { + filesAdd.push(id) + } else if (change.action === "remove") { + filesRemove.push(id) + } + } + + // Add comma-separated lists to form data + if (capturesAdd.length > 0) { + formData.append("captures_add", capturesAdd.join(",")) + } + if (capturesRemove.length > 0) { + formData.append("captures_remove", capturesRemove.join(",")) + } + if (filesAdd.length > 0) { + formData.append("files_add", filesAdd.join(",")) + } + if (filesRemove.length > 0) { + formData.append("files_remove", filesRemove.join(",")) + } + + // Add author changes if they exist + if ( + this.authorChanges && + (this.authorChanges.added.length > 0 || + this.authorChanges.removed.length > 0 || + Object.keys(this.authorChanges.modified).length > 0) + ) { + formData.append( + "author_changes", + JSON.stringify(this.authorChanges), + ) + } + + // Submit the form + this.submitForm(formData) + } + + /** + * Submit the form with pending changes + * @param {FormData} formData - Form data to submit + */ + async submitForm(formData) { + try { + // Show loading state + const submitBtn = document.getElementById("submitForm") + if (submitBtn) { + submitBtn.disabled = true + submitBtn.innerHTML = + 'Updating...' + } + + // Submit form + const response = await fetch(window.location.href, { + method: "POST", + body: formData, + headers: { + "X-CSRFToken": document.querySelector( + "[name=csrfmiddlewaretoken]", + ).value, + }, + }) + + if (response.ok) { + // Success - redirect or show success message + const result = await response.json() + if (result.success) { + // Redirect to dataset list or show success message + window.location.href = + result.redirect_url || "/users/dataset-list/" + } else { + // Show error message + this.showToast( + result.message || + "An error occurred while updating the dataset.", + "error", + ) + } + } else { + // Handle error response + this.showToast( + "An error occurred while updating the dataset.", + "error", + ) + } + } catch (error) { + console.error("Error submitting form:", error) + this.showToast( + "An error occurred while updating the dataset.", + "error", + ) + } finally { + // Restore submit button + const submitBtn = document.getElementById("submitForm") + if (submitBtn) { + submitBtn.disabled = false + submitBtn.innerHTML = "Update Dataset" + } + } + } + + /** + * Initialize authors management for edit mode + */ + initializeAuthorsManagement() { + window.DatasetAuthorsUI?.mount(this, { + mode: "edit", + initialAuthors: this.initialAuthors, + }) + } + + /** + * Update dataset authors with pending changes (for review display) + */ + async updateDatasetAuthors(authorsField) { + const authorsElement = document.querySelector(".dataset-authors") + if (!authorsElement) return + + if (!authorsField) { + // In contributor view, there's no editable authors field, so show original authors + const originalAuthors = + window.datasetModeManager?.originalDatasetData?.authors || [] + const originalAuthorNames = this.formatAuthors(originalAuthors) + authorsElement.textContent = originalAuthorNames + return + } + + try { + // Get current authors with DOM-based stable IDs + const currentAuthorsWithIds = this.getCurrentAuthorsWithDOMIds() + // Get original authors from DatasetModeManager's captured data + const originalAuthors = + window.datasetModeManager?.originalDatasetData?.authors || [] + + // Format original authors for display + const originalAuthorNames = this.formatAuthors(originalAuthors) + + // Always show original value + authorsElement.innerHTML = `${originalAuthorNames}` + + // Calculate changes using DOM-based IDs + const changes = this.calculateAuthorChanges( + originalAuthors, + currentAuthorsWithIds, + ) + + // If there are changes, request server-side rendering + if (changes.length > 0) { + try { + // Normalize for generic change_list template + const normalizedChanges = changes.map((change) => { + if (change.type === "add") { + return { + type: "add", + parts: [ + { text: "Add: " }, + { + text: change.name, + css_class: "text-success", + }, + ], + } + } + if (change.type === "remove") { + return { + type: "remove", + parts: [ + { text: "Remove: " }, + { + text: change.name, + css_class: "text-danger", + }, + ], + } + } + if (change.type === "change") { + // Handle name changes + if ( + change.oldName !== undefined && + change.newName !== undefined + ) { + return { + type: "change", + parts: [ + { text: 'Change Name: "' }, + { text: change.oldName }, + { text: '" → ' }, + { + text: `"${change.newName}"`, + css_class: "text-warning", + }, + ], + } + } + // Handle ORCID changes + if ( + change.oldOrcid !== undefined && + change.newOrcid !== undefined + ) { + const oldOrcidDisplay = change.oldOrcid || "" + const newOrcidDisplay = change.newOrcid || "" + return { + type: "change", + parts: [ + { text: 'Change ORCID ID: "' }, + { text: oldOrcidDisplay }, + { text: '" → ' }, + { + text: `"${newOrcidDisplay}"`, + css_class: "text-warning", + }, + ], + } + } + } + return change + }) + + // Request server to render using generic change_list + const response = await window.APIClient.post( + "/users/render-html/", + { + template: "users/components/change_list.html", + context: { changes: normalizedChanges }, + }, + null, + true, + ) // true = send as JSON + + // Insert the server-rendered HTML + if (response.html) { + authorsElement.insertAdjacentHTML( + "beforeend", + response.html, + ) + } + } catch (error) { + console.error("Error rendering author changes:", error) + // Fallback: show error message + authorsElement.insertAdjacentHTML( + "beforeend", + '
    Error loading changes
    ', + ) + } + } + } catch (e) { + console.error("Error in updateDatasetAuthors:", e) + authorsElement.innerHTML = + 'Error parsing authors.' + } + } + + /** + * Get current authors with DOM-based stable IDs + */ + getCurrentAuthorsWithDOMIds() { + return window.AuthorsManager.getCurrentAuthorsWithDOMIds() + } + + /** + * Capture authors with DOM-based stable IDs + */ + captureAuthorsWithDOMIds(authors) { + const authorsList = document.querySelector(".authors-list") + const authorsWithIds = [] + + if (authorsList) { + // Get author items from DOM + const authorItems = authorsList.querySelectorAll(".author-item") + + for (const [index, authorItem] of authorItems.entries()) { + // Get or create a stable ID for this author item + const authorId = authorItem.id + if (!authorId) { + console.error("❌ Author item missing ID") + return + } + + // Get the author data (either from the authors array or from DOM inputs) + let authorData + if (authors[index]) { + authorData = + typeof authors[index] === "string" + ? { name: authors[index], orcid_id: "" } + : { ...authors[index] } + } else { + // Fallback to DOM inputs if author data is missing + const nameInput = + authorItem.querySelector(".author-name-input") + const orcidInput = authorItem.querySelector( + ".author-orcid-input", + ) + authorData = { + name: nameInput?.value || "", + orcid_id: orcidInput?.value || "", + } + } + + // Add the stable ID + authorData._stableId = authorId + authorsWithIds.push(authorData) + } + } + + return authorsWithIds + } + + /** + * Format authors array into display string + */ + formatAuthors(authors) { + return window.AuthorsManager.formatAuthors(authors) + } + + /** + * Calculate author changes between original and current + */ + calculateAuthorChanges(originalAuthors, currentAuthors) { + const changes = [] + + // Create maps using stable IDs + const originalMap = new Map() + const currentMap = new Map() + + for (const author of originalAuthors) { + if (author._stableId) { + originalMap.set(author._stableId, author) + } + } + + for (const author of currentAuthors) { + if (author._stableId) { + currentMap.set(author._stableId, author) + } + } + + // Find additions (in current but not in original) + for (const [id, author] of currentMap) { + if (!originalMap.has(id)) { + const name = author.name || "Unknown" + changes.push({ type: "add", name }) + } + } + + // Find removals (in original but not in current) + for (const [id, author] of originalMap) { + if (!currentMap.has(id)) { + const name = author.name || "Unknown" + changes.push({ type: "remove", name }) + } + } + + // Find changes (same ID but different content) + for (const [id, currentAuthor] of currentMap) { + const originalAuthor = originalMap.get(id) + if (originalAuthor) { + const currentName = currentAuthor.name || "Unknown" + const originalName = originalAuthor.name || "Unknown" + + const currentOrcid = currentAuthor.orcid_id || "" + const originalOrcid = originalAuthor.orcid_id || "" + + if (currentName !== originalName) { + changes.push({ + type: "change", + oldName: originalName, + newName: currentName, + }) + } + if (currentOrcid !== originalOrcid) { + changes.push({ + type: "change", + oldOrcid: originalOrcid, + newOrcid: currentOrcid, + }) + } + } + } + + return changes + } } // Make class available globally diff --git a/gateway/sds_gateway/static/js/search/AssetSearchHandler.js b/gateway/sds_gateway/static/js/search/AssetSearchHandler.js index 84450fef3..39c89dbee 100644 --- a/gateway/sds_gateway/static/js/search/AssetSearchHandler.js +++ b/gateway/sds_gateway/static/js/search/AssetSearchHandler.js @@ -17,37 +17,37 @@ function getConfiguredSearchElements(config) { } class AssetSearchHandler { - static FILE_TREE_ROOT_ID = "file-tree-root"; - static FILE_TREE_FILE_CHECKBOX_SELECTOR = - '#file-tree-root input[name="files"]'; - - /** - * @returns {HTMLElement|null} - */ - getFileTreeRoot() { - return document.getElementById(AssetSearchHandler.FILE_TREE_ROOT_ID); - } - - /** - * @returns {HTMLInputElement[]} - */ - getVisibleFileCheckboxes() { - return Array.from( - document.querySelectorAll( - `${AssetSearchHandler.FILE_TREE_FILE_CHECKBOX_SELECTOR}:not(:disabled)`, - ), - ).filter((checkbox) => checkbox.offsetParent !== null); - } - /** - * @param {object} target - * @param {object} config - */ - static applySearchCoreElements(target, config) { - const searchEls = getConfiguredSearchElements(config); - target.searchForm = searchEls.searchForm; - target.searchButton = searchEls.searchButton; - target.clearButton = searchEls.clearButton; - } + static FILE_TREE_ROOT_ID = "file-tree-root" + static FILE_TREE_FILE_CHECKBOX_SELECTOR = + '#file-tree-root input[name="files"]' + + /** + * @returns {HTMLElement|null} + */ + getFileTreeRoot() { + return document.getElementById(AssetSearchHandler.FILE_TREE_ROOT_ID) + } + + /** + * @returns {HTMLInputElement[]} + */ + getVisibleFileCheckboxes() { + return Array.from( + document.querySelectorAll( + `${AssetSearchHandler.FILE_TREE_FILE_CHECKBOX_SELECTOR}:not(:disabled)`, + ), + ).filter((checkbox) => checkbox.offsetParent !== null) + } + /** + * @param {object} target + * @param {object} config + */ + static applySearchCoreElements(target, config) { + const searchEls = getConfiguredSearchElements(config) + target.searchForm = searchEls.searchForm + target.searchButton = searchEls.searchButton + target.clearButton = searchEls.clearButton + } /** * Build URLSearchParams from config specs ({ param, elementId } or { param, el, get }). @@ -407,9 +407,9 @@ class AssetSearchHandler { selectAllCheckbox.dataset.selectAllBound = "true" } - selectAllCheckbox.addEventListener("change", () => { - const isChecked = selectAllCheckbox.checked; - const fileCheckboxes = this.getVisibleFileCheckboxes(); + selectAllCheckbox.addEventListener("change", () => { + const isChecked = selectAllCheckbox.checked + const fileCheckboxes = this.getVisibleFileCheckboxes() for (const checkbox of fileCheckboxes) { if (checkbox.checked !== isChecked) { @@ -433,20 +433,20 @@ class AssetSearchHandler { removeAllButton.dataset.removeAllBound = "true" } - removeAllButton.addEventListener("click", () => { - // Check if formHandler has a custom removal handler for edit mode - if (this.formHandler?.handleRemoveAllFiles) { - this.formHandler.handleRemoveAllFiles(); - } else { - // Default behavior for create mode - // Deselect all files - const fileCheckboxes = document.querySelectorAll( - AssetSearchHandler.FILE_TREE_FILE_CHECKBOX_SELECTOR, - ); - for (const checkbox of fileCheckboxes) { - checkbox.checked = false; - checkbox.dispatchEvent(new Event("change")); - } + removeAllButton.addEventListener("click", () => { + // Check if formHandler has a custom removal handler for edit mode + if (this.formHandler?.handleRemoveAllFiles) { + this.formHandler.handleRemoveAllFiles() + } else { + // Default behavior for create mode + // Deselect all files + const fileCheckboxes = document.querySelectorAll( + AssetSearchHandler.FILE_TREE_FILE_CHECKBOX_SELECTOR, + ) + for (const checkbox of fileCheckboxes) { + checkbox.checked = false + checkbox.dispatchEvent(new Event("change")) + } this.selectedFiles.clear() this.updateSelectedFilesList() @@ -536,779 +536,804 @@ class AssetSearchHandler {
    - `; - capturesContainer.appendChild(selectedPane); - } - - /** - * Update selected captures pane - */ - async updateSelectedCapturesPane() { - const selectedList = document.getElementById("selected-captures-list"); - const countBadge = document.querySelector(".selected-captures-count"); - if (!selectedList || !countBadge || !this.formHandler) return; - - const selectedCaptures = this.formHandler.selectedCaptures; - countBadge.textContent = `${selectedCaptures.size} selected`; - - // Prepare captures data for server-side rendering - const capturesData = Array.from(selectedCaptures).map((captureId) => { - const data = this.selectedCaptureDetails.get(captureId) || { - type: "Unknown", - directory: "Unknown", - }; - return { - id: captureId, - type: data.type, - directory: data.directory, - }; - }); - - await this.renderSelectedCapturesTable(selectedList, capturesData); - - // Add remove handlers after async render (DOM must contain buttons) - const removeSelectedButtons = selectedList.querySelectorAll( - ".remove-selected-capture", - ); - for (const button of removeSelectedButtons) { - button.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - const captureId = button.dataset.id; - - // Check if formHandler has a custom removal handler for edit mode - if (this.formHandler?.handleCaptureRemoval) { - this.formHandler.handleCaptureRemoval(captureId); - } else { - // Default behavior for create mode - this.formHandler.selectedCaptures.delete(captureId); - this.selectedCaptureDetails.delete(captureId); - - // Update checkbox if visible - const checkbox = document.querySelector( - `input[name="captures"][value="${captureId}"]`, - ); - if (checkbox) { - checkbox.checked = false; - checkbox.closest("tr").classList.remove("table-warning"); - } - - void this.updateSelectedCapturesPane(); - this.formHandler.updateHiddenFields(); - } - }); - } - } - - /** - * Render selected captures table asynchronously - */ - async renderSelectedCapturesTable(selectedList, capturesData) { - // Normalize for generic table_rows template - const rows = capturesData.map((capture) => ({ - data_attrs: { "capture-id": capture.id }, - cells: [ - { kind: "text", value: capture.type }, - { kind: "text", value: capture.directory }, - ], - actions: [ - { - label: "Remove", - icon: "bi-x", - css_class: "btn-danger", - extra_class: "remove-selected-capture", - data_attrs: { id: capture.id }, - }, - ], - })); - - // Use the generic table_rows template via DOMUtils - const success = await window.DOMUtils.renderTable(selectedList, rows, { - empty_message: "No captures selected", - empty_colspan: 3, - }); - - if (!success) { - console.error("Error rendering selected captures table"); - } - } - - /** - * Fetch captures data - * @param {Object} params - Search parameters - * @returns {Promise} Captures data - */ - async fetchCaptures(params = {}) { - try { - const searchParams = new URLSearchParams(); - - // Add all params to the search parameters - for (const [key, value] of Object.entries(params)) { - if (value) { - searchParams.append(key, value); - } - } - - // Always add the search_captures parameter - searchParams.append("search_captures", "true"); - - const data = await window.APIClient.request( - `${this.config.apiEndpoint}?${searchParams.toString()}`, - { - headers: { - "X-Requested-With": "XMLHttpRequest", - }, - }, - ); - - // APIClient.request already returns parsed JSON data - return data; - } catch (error) { - console.error("Error fetching captures:", error); - return { results: [], pagination: {} }; - } - } - - /** - * Fetch files data - * @param {Object} params - Search parameters - * @returns {Promise} Files data - */ - async fetchFiles(params = {}) { - try { - const searchParams = new URLSearchParams(params); - const data = await window.APIClient.request( - `${this.config.apiEndpoint}?${searchParams.toString()}&search_files=true`, - { - headers: { - "X-Requested-With": "XMLHttpRequest", - }, - }, - ); - - // APIClient.request already returns parsed JSON data - return data; - } catch (error) { - console.error("Error fetching files:", error); - return { tree: {}, pagination: {} }; - } - } - - /** - * Update captures table - * @param {Object} data - Captures data - */ - async updateCapturesTable(data) { - const tbody = document.querySelector("#captures-table tbody"); - - // Update the results count - this.updateResultsCount(data.results.length); - - // Transform captures data for table_rows.html template - const rows = data.results.map((capture) => { - const captureIdStr = capture.id.toString(); - const inExistingDataset = - this.isEditMode && - this.formHandler?.currentCaptures && - (this.formHandler.currentCaptures.has(capture.id) || - this.formHandler.currentCaptures.has(captureIdStr)); - const isSelected = - inExistingDataset || - this.formHandler?.selectedCaptures?.has(captureIdStr); - const isOwnedByCurrentUser = - capture.owner_id === this.formHandler?.currentUserId; - const canSelect = isOwnedByCurrentUser; - // Edit only: captures already in the dataset stay checked and locked - const checkboxDisabled = - !canSelect || (this.isEditMode && inExistingDataset); - const ownerName = capture.owner - ? capture.owner.name || capture.owner.email || "-" - : "-"; - const createdAt = new Date(capture.created_at).toLocaleDateString( - "en-US", - { - month: "2-digit", - day: "2-digit", - year: "numeric", - }, - ); - - return { - id: capture.id, - css_class: `capture-row${isSelected ? " table-warning" : ""}${checkboxDisabled ? " readonly-row" : ""}${inExistingDataset ? " capture-in-dataset" : ""}`, - data_attrs: { - "capture-id": capture.id, - }, - cells: [ - { - kind: "html", - tag: "input", - class: "form-check-input capture-checkbox", - name: "captures", - tag_attrs: { - type: "checkbox", - checked: isSelected, - disabled: checkboxDisabled, - value: capture.id, - }, - data_attrs: { - "capture-type": capture.type, - "capture-directory": capture.directory, - "capture-channel": capture.channel, - "capture-scan-group": capture.scan_group, - "capture-created-at": capture.created_at, - "capture-owner-id": capture.owner_id, - "capture-owner-name": ownerName, - }, - }, - { kind: "text", value: capture.type }, - { kind: "text", value: capture.directory }, - { kind: "text", value: capture.channel }, - { kind: "text", value: capture.scan_group }, - { kind: "text", value: ownerName }, - { kind: "text", value: createdAt }, - ], - }; - }); - - // Render using DOMUtils - const success = await window.DOMUtils.renderTable(tbody, rows, { - empty_message: "No captures found", - empty_colspan: 7, - }); - - if (success) { - // Attach event handlers to rendered rows - this.attachCaptureRowHandlers(tbody); - } else { - await window.DOMUtils.showMessage("Error loading captures", { - variant: "danger", - placement: "replace", - target: tbody, - presentation: "table", - templateContext: { colspan: 7 }, - }); - } - - // Update pagination with current filters - this.updatePagination("captures", data.pagination); - - // Update selected captures pane - await this.updateSelectedCapturesPane(); - } - - /** - * Attach event handlers to capture table rows - * @param {Element} tbody - Table body element - */ - attachCaptureRowHandlers(tbody) { - const rows = tbody.querySelectorAll("tr[data-capture-id]"); - - for (const row of rows) { - const checkbox = row.querySelector("input.capture-checkbox"); - if (!checkbox) continue; - - const captureId = checkbox.value; - const captureData = { - type: checkbox.dataset.captureType, - directory: checkbox.dataset.captureDirectory, - channel: checkbox.dataset.captureChannel, - scan_group: checkbox.dataset.captureScanGroup, - created_at: checkbox.dataset.captureCreatedAt, - owner_id: checkbox.dataset.captureOwnerId, - owner_name: checkbox.dataset.captureOwnerName, - }; - - const handleSelection = (e) => { - if (checkbox.disabled) { - e.preventDefault(); - e.stopPropagation(); - return; - } - - if (e.target.type !== "checkbox") { - checkbox.checked = !checkbox.checked; - } - - if (checkbox.checked) { - // Check if this is an editing handler - if (this.formHandler.addCaptureToPending) { - this.formHandler.addCaptureToPending(captureId, captureData); - } else { - // Regular selection for creation - this.formHandler.selectedCaptures.add(captureId); - row.classList.add("table-warning"); - this.selectedCaptureDetails.set(captureId, captureData); - this.formHandler.updateHiddenFields(); - void this.updateSelectedCapturesPane(); - } - - if (this.formHandler.updateCurrentCapturesList) { - this.formHandler.updateCurrentCapturesList(); - } - } else { - if (this.formHandler.addCaptureToPending) { - this.formHandler.cancelCaptureChange(captureId); - } else { - this.formHandler.selectedCaptures.delete(captureId); - row.classList.remove("table-warning"); - this.selectedCaptureDetails.delete(captureId); - this.formHandler.updateHiddenFields(); - void this.updateSelectedCapturesPane(); - } - - if (this.formHandler.updateCurrentCapturesList) { - this.formHandler.updateCurrentCapturesList(); - } - } - }; - - // Add click handler for the row - row.addEventListener("click", handleSelection); - - // Add specific handler for checkbox to prevent double-triggering - checkbox.addEventListener("change", (e) => { - e.stopPropagation(); - handleSelection(e); - }); - } - } - - /** - * Update pagination - * @param {string} type - Type of pagination (captures or files) - * @param {Object} pagination - Pagination data - */ - async updatePagination(type, pagination) { - const paginationContainer = document.querySelector(`#${type}-pagination`); - if (!paginationContainer) return; - - const success = await window.DOMUtils.renderPagination( - paginationContainer, - pagination, - ); - - if (success && pagination && pagination.num_pages > 1) { - // Attach click handlers after rendering - this.attachPaginationHandlers(type, paginationContainer); - } - } - - /** - * Attach pagination click handlers - * @param {string} type - Type of pagination (captures or files) - * @param {Element} container - Pagination container element - */ - attachPaginationHandlers(type, container) { - const links = container.querySelectorAll("a.page-link"); - for (const link of links) { - link.addEventListener("click", async (e) => { - e.preventDefault(); - const target = e.target.closest("a.page-link"); - const page = target?.dataset.page; - - if (type === "captures") { - const params = { - ...this.currentFilters, - page: page, - }; - const data = await this.fetchCaptures(params); - this.updateCapturesTable(data); - } else { - const data = await this.fetchFiles( - this.getFileSearchParams({ page }), - ); - this.updateFilesTable(data); - } - }); - } - } - - /** - * Handle search - */ - async handleSearch() { - try { - // Get all input elements within the search container - const searchContainer = this.searchForm; - if (!searchContainer) { - console.error("Search container not found:", this.searchForm); - return; - } - const params = new URLSearchParams(); - - // Get all form inputs within the container - const inputs = searchContainer.querySelectorAll( - "input, select, textarea", - ); - for (const input of inputs) { - if (input.value) { - params.append(input.name, input.value); - } - } - - // Add the search type parameter - params.append( - this.type === "captures" ? "search_captures" : "search_files", - "true", - ); - - const data = await window.APIClient.request( - `${this.config.apiEndpoint}?${params.toString()}`, - { - headers: { - "X-Requested-With": "XMLHttpRequest", - }, - }, - ); - - // APIClient.request already returns parsed JSON data - - if (this.type === "captures") { - this.updateCapturesTable(data); - } else { - // Reset select all checkbox - const selectAllCheckbox = document.getElementById( - "select-all-files-checkbox", - ); - if (selectAllCheckbox) { - selectAllCheckbox.checked = false; - } - if (data.tree) { - // Update file extension select options while preserving current selection - const extensionSelect = document.getElementById("file-extension"); - if (extensionSelect && data.extension_choices) { - const currentValue = extensionSelect.value; - await window.DOMUtils.renderSelectOptions( - extensionSelect, - data.extension_choices, - currentValue, - ); - } - - // Restore search values if they exist - if (data.search_values) { - const fileNameInput = document.getElementById("file-name"); - const directoryInput = document.getElementById("file-directory"); - - if (fileNameInput) { - fileNameInput.value = data.search_values.file_name || ""; - } - if (extensionSelect) { - extensionSelect.value = data.search_values.file_extension || ""; - } - if (directoryInput) { - directoryInput.value = data.search_values.directory || ""; - } - } - - const searchTermEntered = - data.search_values.file_name || - data.search_values.directory || - data.search_values.file_extension; - - this.renderFileTree(data.tree, null, 0, "", searchTermEntered); - - // Initialize select all checkbox handler for the current file tree - this.initializeSelectAllCheckbox(); - - // Initialize remove all button handler for the current file tree - this.initializeRemoveAllButton(); - } - } - - if (data.pagination) { - this.updatePagination(this.type, data.pagination); - } - } catch (error) { - console.error("Error during search:", error); - this.showError("An error occurred during the search. Please try again."); - } - } - - /** - * Handle clear - */ - handleClear() { - // Clear all form inputs - const searchContainer = this.searchForm; - if (!searchContainer) { - console.error("Search container not found:", this.searchForm); - return; - } - - const inputs = searchContainer.querySelectorAll("input, select, textarea"); - for (const input of inputs) { - input.value = ""; - } - - // Trigger a new search with empty parameters - this.handleSearch(); - } - - /** - * Update selected files list - */ - updateSelectedFilesList() { - // Update form handler's selectedFiles with current selection (create mode only) - if (this.formHandler && !this.isEditMode) { - // Convert Map entries to array of file objects with IDs - const fileList = Array.from(this.selectedFiles.entries()).map( - ([id, file]) => ({ ...file, id: id }), - ); - this.formHandler.selectedFiles = new Set(fileList); - } - - // Update selected files display input - const selectedFilesDisplay = document.getElementById( - "selected-files-display", - ); - if (selectedFilesDisplay) { - selectedFilesDisplay.value = `${this.selectedFiles.size} file(s) selected`; - } - - // Update Remove All button state - const removeAllButton = document.getElementById( - "remove-all-selected-files-button", - ); - if (removeAllButton) { - removeAllButton.disabled = this.selectedFiles.size === 0; - } - - // Update selected files table if it exists (only in create mode) - if (!this.isEditMode) { - const selectedFilesTable = document.getElementById( - "selected-files-table", - ); - const selectedFilesBody = selectedFilesTable?.querySelector("tbody"); - if (selectedFilesBody) { - this.renderSelectedFilesTable(selectedFilesBody); - } - } - - // Update count badge - const countBadge = document.querySelector(".selected-files-count"); - if (countBadge) { - countBadge.textContent = `${this.selectedFiles.size} selected`; - } - } - - /** - * Load file tree - */ - async loadFileTree() { - try { - const params = this.getFileSearchParams(); - const extensionSelect = document.getElementById("file-extension"); - const data = await this.fetchFiles(params); - if (!data.tree) { - console.error("No tree data received:", data); - return; - } - - // Update file extension select options - if (extensionSelect && data.extension_choices) { - await window.DOMUtils.renderSelectOptions( - extensionSelect, - data.extension_choices, - ); - } - - // Pass the search parameters to renderFileTree - const searchTermEntered = - params.file_name || params.directory || params.file_extension; - - this.renderFileTree(data.tree, null, 0, "", searchTermEntered); - - // Initialize select all checkbox handler for the current file tree - this.initializeSelectAllCheckbox(); - - // Initialize remove all button handler for the current file tree - this.initializeRemoveAllButton(); - } catch (error) { - console.error("Error loading file tree:", error); - } - } - - /** - * Get relative path - * @param {Object} file - File object - * @param {string} currentPath - Current path - * @returns {string} Relative path - */ - getRelativePath(file, currentPath = "") { - if (!currentPath) { - return ""; - } - return `/${currentPath}`; - } - - /** - * Render file tree - * @param {Object} tree - File tree data - * @param {HTMLElement} parentElement - Parent element - * @param {number} level - Nesting level - * @param {string} currentPath - Current path - * @param {boolean} searchTermEntered - Whether search term was entered - */ - renderFileTree( - tree, - parentElement = null, - level = 0, - currentPath = "", - searchTermEntered = false, - ) { - this.currentTree = tree; - const targetElement = parentElement || this.getFileTreeRoot(); - if (!targetElement) { - console.error("File tree root not found"); - return; - } - - if (!parentElement) { - targetElement.innerHTML = ""; - } - - if ( - !tree || - ((!tree.files || tree.files.length === 0) && - (!tree.children || Object.keys(tree.children).length === 0)) - ) { - targetElement.innerHTML = - '
  • No files or directories found
  • '; - return; - } - - const selectAllContainer = document.getElementById("select-all-container"); - const hasFiles = tree.files && tree.files.length > 0; - if (selectAllContainer) { - if (searchTermEntered && hasFiles) { - window.DOMUtils.show(selectAllContainer); - } else { - window.DOMUtils.hide(selectAllContainer); - } - } - - const directories = tree.children || {}; - - for (const [name, content] of Object.entries(directories)) { - if ( - name === "files" || - !content || - typeof content !== "object" || - !content.type || - content.type !== "directory" - ) { - continue; - } - - const initiallyExpanded = searchTermEntered; - const folderIcon = initiallyExpanded - ? "bi-folder2-open" - : "bi-folder-fill"; - const dirPath = currentPath - ? `${currentPath}/${content.name || name}` - : content.name || name; - const hasChildDirs = - Object.keys(content.children || {}).filter( - (key) => - key !== "files" && - content.children[key]?.type === "directory", - ).length > 0; - const hasFilesInDir = content.files && content.files.length > 0; - const expandable = hasChildDirs || hasFilesInDir; - - const li = document.createElement("li"); - li.className = "folder-item"; - - const rowSpan = document.createElement("span"); - rowSpan.className = "file-browser-row"; - rowSpan.setAttribute("role", "button"); - rowSpan.setAttribute("tabindex", "0"); - rowSpan.setAttribute( - "aria-expanded", - initiallyExpanded ? "true" : "false", - ); - rowSpan.innerHTML = ` + ` + capturesContainer.appendChild(selectedPane) + } + + /** + * Update selected captures pane + */ + async updateSelectedCapturesPane() { + const selectedList = document.getElementById("selected-captures-list") + const countBadge = document.querySelector(".selected-captures-count") + if (!selectedList || !countBadge || !this.formHandler) return + + const selectedCaptures = this.formHandler.selectedCaptures + countBadge.textContent = `${selectedCaptures.size} selected` + + // Prepare captures data for server-side rendering + const capturesData = Array.from(selectedCaptures).map((captureId) => { + const data = this.selectedCaptureDetails.get(captureId) || { + type: "Unknown", + directory: "Unknown", + } + return { + id: captureId, + type: data.type, + directory: data.directory, + } + }) + + await this.renderSelectedCapturesTable(selectedList, capturesData) + + // Add remove handlers after async render (DOM must contain buttons) + const removeSelectedButtons = selectedList.querySelectorAll( + ".remove-selected-capture", + ) + for (const button of removeSelectedButtons) { + button.addEventListener("click", (e) => { + e.preventDefault() + e.stopPropagation() + const captureId = button.dataset.id + + // Check if formHandler has a custom removal handler for edit mode + if (this.formHandler?.handleCaptureRemoval) { + this.formHandler.handleCaptureRemoval(captureId) + } else { + // Default behavior for create mode + this.formHandler.selectedCaptures.delete(captureId) + this.selectedCaptureDetails.delete(captureId) + + // Update checkbox if visible + const checkbox = document.querySelector( + `input[name="captures"][value="${captureId}"]`, + ) + if (checkbox) { + checkbox.checked = false + checkbox.closest("tr").classList.remove("table-warning") + } + + void this.updateSelectedCapturesPane() + this.formHandler.updateHiddenFields() + } + }) + } + } + + /** + * Render selected captures table asynchronously + */ + async renderSelectedCapturesTable(selectedList, capturesData) { + // Normalize for generic table_rows template + const rows = capturesData.map((capture) => ({ + data_attrs: { "capture-id": capture.id }, + cells: [ + { kind: "text", value: capture.type }, + { kind: "text", value: capture.directory }, + ], + actions: [ + { + label: "Remove", + icon: "bi-x", + css_class: "btn-danger", + extra_class: "remove-selected-capture", + data_attrs: { id: capture.id }, + }, + ], + })) + + // Use the generic table_rows template via DOMUtils + const success = await window.DOMUtils.renderTable(selectedList, rows, { + empty_message: "No captures selected", + empty_colspan: 3, + }) + + if (!success) { + console.error("Error rendering selected captures table") + } + } + + /** + * Fetch captures data + * @param {Object} params - Search parameters + * @returns {Promise} Captures data + */ + async fetchCaptures(params = {}) { + try { + const searchParams = new URLSearchParams() + + // Add all params to the search parameters + for (const [key, value] of Object.entries(params)) { + if (value) { + searchParams.append(key, value) + } + } + + // Always add the search_captures parameter + searchParams.append("search_captures", "true") + + const data = await window.APIClient.request( + `${this.config.apiEndpoint}?${searchParams.toString()}`, + { + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + }, + ) + + // APIClient.request already returns parsed JSON data + return data + } catch (error) { + console.error("Error fetching captures:", error) + return { results: [], pagination: {} } + } + } + + /** + * Fetch files data + * @param {Object} params - Search parameters + * @returns {Promise} Files data + */ + async fetchFiles(params = {}) { + try { + const searchParams = new URLSearchParams(params) + const data = await window.APIClient.request( + `${this.config.apiEndpoint}?${searchParams.toString()}&search_files=true`, + { + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + }, + ) + + // APIClient.request already returns parsed JSON data + return data + } catch (error) { + console.error("Error fetching files:", error) + return { tree: {}, pagination: {} } + } + } + + /** + * Update captures table + * @param {Object} data - Captures data + */ + async updateCapturesTable(data) { + const tbody = document.querySelector("#captures-table tbody") + + // Update the results count + this.updateResultsCount(data.results.length) + + // Transform captures data for table_rows.html template + const rows = data.results.map((capture) => { + const captureIdStr = capture.id.toString() + const inExistingDataset = + this.isEditMode && + this.formHandler?.currentCaptures && + (this.formHandler.currentCaptures.has(capture.id) || + this.formHandler.currentCaptures.has(captureIdStr)) + const isSelected = + inExistingDataset || + this.formHandler?.selectedCaptures?.has(captureIdStr) + const isOwnedByCurrentUser = + capture.owner_id === this.formHandler?.currentUserId + const canSelect = isOwnedByCurrentUser + // Edit only: captures already in the dataset stay checked and locked + const checkboxDisabled = + !canSelect || (this.isEditMode && inExistingDataset) + const ownerName = capture.owner + ? capture.owner.name || capture.owner.email || "-" + : "-" + const createdAt = new Date(capture.created_at).toLocaleDateString( + "en-US", + { + month: "2-digit", + day: "2-digit", + year: "numeric", + }, + ) + + return { + id: capture.id, + css_class: `capture-row${isSelected ? " table-warning" : ""}${checkboxDisabled ? " readonly-row" : ""}${inExistingDataset ? " capture-in-dataset" : ""}`, + data_attrs: { + "capture-id": capture.id, + }, + cells: [ + { + kind: "html", + tag: "input", + class: "form-check-input capture-checkbox", + name: "captures", + tag_attrs: { + type: "checkbox", + checked: isSelected, + disabled: checkboxDisabled, + value: capture.id, + }, + data_attrs: { + "capture-type": capture.type, + "capture-directory": capture.directory, + "capture-channel": capture.channel, + "capture-scan-group": capture.scan_group, + "capture-created-at": capture.created_at, + "capture-owner-id": capture.owner_id, + "capture-owner-name": ownerName, + }, + }, + { kind: "text", value: capture.type }, + { kind: "text", value: capture.directory }, + { kind: "text", value: capture.channel }, + { kind: "text", value: capture.scan_group }, + { kind: "text", value: ownerName }, + { kind: "text", value: createdAt }, + ], + } + }) + + // Render using DOMUtils + const success = await window.DOMUtils.renderTable(tbody, rows, { + empty_message: "No captures found", + empty_colspan: 7, + }) + + if (success) { + // Attach event handlers to rendered rows + this.attachCaptureRowHandlers(tbody) + } else { + await window.DOMUtils.showMessage("Error loading captures", { + variant: "danger", + placement: "replace", + target: tbody, + presentation: "table", + templateContext: { colspan: 7 }, + }) + } + + // Update pagination with current filters + this.updatePagination("captures", data.pagination) + + // Update selected captures pane + await this.updateSelectedCapturesPane() + } + + /** + * Attach event handlers to capture table rows + * @param {Element} tbody - Table body element + */ + attachCaptureRowHandlers(tbody) { + const rows = tbody.querySelectorAll("tr[data-capture-id]") + + for (const row of rows) { + const checkbox = row.querySelector("input.capture-checkbox") + if (!checkbox) continue + + const captureId = checkbox.value + const captureData = { + type: checkbox.dataset.captureType, + directory: checkbox.dataset.captureDirectory, + channel: checkbox.dataset.captureChannel, + scan_group: checkbox.dataset.captureScanGroup, + created_at: checkbox.dataset.captureCreatedAt, + owner_id: checkbox.dataset.captureOwnerId, + owner_name: checkbox.dataset.captureOwnerName, + } + + const handleSelection = (e) => { + if (checkbox.disabled) { + e.preventDefault() + e.stopPropagation() + return + } + + if (e.target.type !== "checkbox") { + checkbox.checked = !checkbox.checked + } + + if (checkbox.checked) { + // Check if this is an editing handler + if (this.formHandler.addCaptureToPending) { + this.formHandler.addCaptureToPending( + captureId, + captureData, + ) + } else { + // Regular selection for creation + this.formHandler.selectedCaptures.add(captureId) + row.classList.add("table-warning") + this.selectedCaptureDetails.set(captureId, captureData) + this.formHandler.updateHiddenFields() + void this.updateSelectedCapturesPane() + } + + if (this.formHandler.updateCurrentCapturesList) { + this.formHandler.updateCurrentCapturesList() + } + } else { + if (this.formHandler.addCaptureToPending) { + this.formHandler.cancelCaptureChange(captureId) + } else { + this.formHandler.selectedCaptures.delete(captureId) + row.classList.remove("table-warning") + this.selectedCaptureDetails.delete(captureId) + this.formHandler.updateHiddenFields() + void this.updateSelectedCapturesPane() + } + + if (this.formHandler.updateCurrentCapturesList) { + this.formHandler.updateCurrentCapturesList() + } + } + } + + // Add click handler for the row + row.addEventListener("click", handleSelection) + + // Add specific handler for checkbox to prevent double-triggering + checkbox.addEventListener("change", (e) => { + e.stopPropagation() + handleSelection(e) + }) + } + } + + /** + * Update pagination + * @param {string} type - Type of pagination (captures or files) + * @param {Object} pagination - Pagination data + */ + async updatePagination(type, pagination) { + const paginationContainer = document.querySelector( + `#${type}-pagination`, + ) + if (!paginationContainer) return + + const success = await window.DOMUtils.renderPagination( + paginationContainer, + pagination, + ) + + if (success && pagination && pagination.num_pages > 1) { + // Attach click handlers after rendering + this.attachPaginationHandlers(type, paginationContainer) + } + } + + /** + * Attach pagination click handlers + * @param {string} type - Type of pagination (captures or files) + * @param {Element} container - Pagination container element + */ + attachPaginationHandlers(type, container) { + const links = container.querySelectorAll("a.page-link") + for (const link of links) { + link.addEventListener("click", async (e) => { + e.preventDefault() + const target = e.target.closest("a.page-link") + const page = target?.dataset.page + + if (type === "captures") { + const params = { + ...this.currentFilters, + page: page, + } + const data = await this.fetchCaptures(params) + this.updateCapturesTable(data) + } else { + const data = await this.fetchFiles( + this.getFileSearchParams({ page }), + ) + this.updateFilesTable(data) + } + }) + } + } + + /** + * Handle search + */ + async handleSearch() { + try { + // Get all input elements within the search container + const searchContainer = this.searchForm + if (!searchContainer) { + console.error("Search container not found:", this.searchForm) + return + } + const params = new URLSearchParams() + + // Get all form inputs within the container + const inputs = searchContainer.querySelectorAll( + "input, select, textarea", + ) + for (const input of inputs) { + if (input.value) { + params.append(input.name, input.value) + } + } + + // Add the search type parameter + params.append( + this.type === "captures" ? "search_captures" : "search_files", + "true", + ) + + const data = await window.APIClient.request( + `${this.config.apiEndpoint}?${params.toString()}`, + { + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + }, + ) + + // APIClient.request already returns parsed JSON data + + if (this.type === "captures") { + this.updateCapturesTable(data) + } else { + // Reset select all checkbox + const selectAllCheckbox = document.getElementById( + "select-all-files-checkbox", + ) + if (selectAllCheckbox) { + selectAllCheckbox.checked = false + } + if (data.tree) { + // Update file extension select options while preserving current selection + const extensionSelect = + document.getElementById("file-extension") + if (extensionSelect && data.extension_choices) { + const currentValue = extensionSelect.value + await window.DOMUtils.renderSelectOptions( + extensionSelect, + data.extension_choices, + currentValue, + ) + } + + // Restore search values if they exist + if (data.search_values) { + const fileNameInput = + document.getElementById("file-name") + const directoryInput = + document.getElementById("file-directory") + + if (fileNameInput) { + fileNameInput.value = + data.search_values.file_name || "" + } + if (extensionSelect) { + extensionSelect.value = + data.search_values.file_extension || "" + } + if (directoryInput) { + directoryInput.value = + data.search_values.directory || "" + } + } + + const searchTermEntered = + data.search_values.file_name || + data.search_values.directory || + data.search_values.file_extension + + this.renderFileTree( + data.tree, + null, + 0, + "", + searchTermEntered, + ) + + // Initialize select all checkbox handler for the current file tree + this.initializeSelectAllCheckbox() + + // Initialize remove all button handler for the current file tree + this.initializeRemoveAllButton() + } + } + + if (data.pagination) { + this.updatePagination(this.type, data.pagination) + } + } catch (error) { + console.error("Error during search:", error) + this.showError( + "An error occurred during the search. Please try again.", + ) + } + } + + /** + * Handle clear + */ + handleClear() { + // Clear all form inputs + const searchContainer = this.searchForm + if (!searchContainer) { + console.error("Search container not found:", this.searchForm) + return + } + + const inputs = searchContainer.querySelectorAll( + "input, select, textarea", + ) + for (const input of inputs) { + input.value = "" + } + + // Trigger a new search with empty parameters + this.handleSearch() + } + + /** + * Update selected files list + */ + updateSelectedFilesList() { + // Update form handler's selectedFiles with current selection (create mode only) + if (this.formHandler && !this.isEditMode) { + // Convert Map entries to array of file objects with IDs + const fileList = Array.from(this.selectedFiles.entries()).map( + ([id, file]) => ({ ...file, id: id }), + ) + this.formHandler.selectedFiles = new Set(fileList) + } + + // Update selected files display input + const selectedFilesDisplay = document.getElementById( + "selected-files-display", + ) + if (selectedFilesDisplay) { + selectedFilesDisplay.value = `${this.selectedFiles.size} file(s) selected` + } + + // Update Remove All button state + const removeAllButton = document.getElementById( + "remove-all-selected-files-button", + ) + if (removeAllButton) { + removeAllButton.disabled = this.selectedFiles.size === 0 + } + + // Update selected files table if it exists (only in create mode) + if (!this.isEditMode) { + const selectedFilesTable = document.getElementById( + "selected-files-table", + ) + const selectedFilesBody = selectedFilesTable?.querySelector("tbody") + if (selectedFilesBody) { + this.renderSelectedFilesTable(selectedFilesBody) + } + } + + // Update count badge + const countBadge = document.querySelector(".selected-files-count") + if (countBadge) { + countBadge.textContent = `${this.selectedFiles.size} selected` + } + } + + /** + * Load file tree + */ + async loadFileTree() { + try { + const params = this.getFileSearchParams() + const extensionSelect = document.getElementById("file-extension") + const data = await this.fetchFiles(params) + if (!data.tree) { + console.error("No tree data received:", data) + return + } + + // Update file extension select options + if (extensionSelect && data.extension_choices) { + await window.DOMUtils.renderSelectOptions( + extensionSelect, + data.extension_choices, + ) + } + + // Pass the search parameters to renderFileTree + const searchTermEntered = + params.file_name || params.directory || params.file_extension + + this.renderFileTree(data.tree, null, 0, "", searchTermEntered) + + // Initialize select all checkbox handler for the current file tree + this.initializeSelectAllCheckbox() + + // Initialize remove all button handler for the current file tree + this.initializeRemoveAllButton() + } catch (error) { + console.error("Error loading file tree:", error) + } + } + + /** + * Get relative path + * @param {Object} file - File object + * @param {string} currentPath - Current path + * @returns {string} Relative path + */ + getRelativePath(file, currentPath = "") { + if (!currentPath) { + return "" + } + return `/${currentPath}` + } + + /** + * Render file tree + * @param {Object} tree - File tree data + * @param {HTMLElement} parentElement - Parent element + * @param {number} level - Nesting level + * @param {string} currentPath - Current path + * @param {boolean} searchTermEntered - Whether search term was entered + */ + renderFileTree( + tree, + parentElement = null, + level = 0, + currentPath = "", + searchTermEntered = false, + ) { + this.currentTree = tree + const targetElement = parentElement || this.getFileTreeRoot() + if (!targetElement) { + console.error("File tree root not found") + return + } + + if (!parentElement) { + targetElement.innerHTML = "" + } + + if ( + !tree || + ((!tree.files || tree.files.length === 0) && + (!tree.children || Object.keys(tree.children).length === 0)) + ) { + targetElement.innerHTML = + '
  • No files or directories found
  • ' + return + } + + const selectAllContainer = document.getElementById( + "select-all-container", + ) + const hasFiles = tree.files && tree.files.length > 0 + if (selectAllContainer) { + if (searchTermEntered && hasFiles) { + window.DOMUtils.show(selectAllContainer) + } else { + window.DOMUtils.hide(selectAllContainer) + } + } + + const directories = tree.children || {} + + for (const [name, content] of Object.entries(directories)) { + if ( + name === "files" || + !content || + typeof content !== "object" || + !content.type || + content.type !== "directory" + ) { + continue + } + + const initiallyExpanded = searchTermEntered + const folderIcon = initiallyExpanded + ? "bi-folder2-open" + : "bi-folder-fill" + const dirPath = currentPath + ? `${currentPath}/${content.name || name}` + : content.name || name + const hasChildDirs = + Object.keys(content.children || {}).filter( + (key) => + key !== "files" && + content.children[key]?.type === "directory", + ).length > 0 + const hasFilesInDir = content.files && content.files.length > 0 + const expandable = hasChildDirs || hasFilesInDir + + const li = document.createElement("li") + li.className = "folder-item" + + const rowSpan = document.createElement("span") + rowSpan.className = "file-browser-row" + rowSpan.setAttribute("role", "button") + rowSpan.setAttribute("tabindex", "0") + rowSpan.setAttribute( + "aria-expanded", + initiallyExpanded ? "true" : "false", + ) + rowSpan.innerHTML = ` ${content.name || name} - `; - - const childUl = document.createElement("ul"); - childUl.setAttribute("role", "group"); - - li.appendChild(rowSpan); - li.appendChild(childUl); - targetElement.appendChild(li); - - if (initiallyExpanded && expandable) { - this.renderFileTree( - content, - childUl, - level + 1, - dirPath, - searchTermEntered, - ); - childUl.dataset.loaded = "true"; - } - - rowSpan.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - if (!expandable) { - return; - } - - const isExpanded = rowSpan.getAttribute("aria-expanded") === "true"; - const newExpanded = !isExpanded; - rowSpan.setAttribute( - "aria-expanded", - newExpanded ? "true" : "false", - ); - - const icon = rowSpan.querySelector(".bi"); - if (icon) { - icon.classList.remove("bi-folder-fill", "bi-folder2-open"); - icon.classList.add( - newExpanded ? "bi-folder2-open" : "bi-folder-fill", - ); - } - - if (newExpanded && childUl.dataset.loaded !== "true") { - this.renderFileTree( - content, - childUl, - level + 1, - dirPath, - searchTermEntered, - ); - childUl.dataset.loaded = "true"; - } - }); - } - - if (tree.files && tree.files.length > 0) { - for (const file of tree.files) { - const filePath = this.getRelativePath(file, currentPath); - const isSelected = this.selectedFiles.has(file.id); - const isExistingFile = - this.isEditMode && this.formHandler?.currentFiles?.has(file.id); - - const li = document.createElement("li"); - li.className = "file-item"; - li.dataset.fileId = file.id; - - const rowSpan = document.createElement("span"); - rowSpan.className = "file-browser-row"; - rowSpan.setAttribute("role", "option"); - rowSpan.setAttribute( - "aria-selected", - isSelected ? "true" : "false", - ); - rowSpan.setAttribute("tabindex", "0"); - rowSpan.innerHTML = ` + ` + + const childUl = document.createElement("ul") + childUl.setAttribute("role", "group") + + li.appendChild(rowSpan) + li.appendChild(childUl) + targetElement.appendChild(li) + + if (initiallyExpanded && expandable) { + this.renderFileTree( + content, + childUl, + level + 1, + dirPath, + searchTermEntered, + ) + childUl.dataset.loaded = "true" + } + + rowSpan.addEventListener("click", (e) => { + e.preventDefault() + e.stopPropagation() + if (!expandable) { + return + } + + const isExpanded = + rowSpan.getAttribute("aria-expanded") === "true" + const newExpanded = !isExpanded + rowSpan.setAttribute( + "aria-expanded", + newExpanded ? "true" : "false", + ) + + const icon = rowSpan.querySelector(".bi") + if (icon) { + icon.classList.remove("bi-folder-fill", "bi-folder2-open") + icon.classList.add( + newExpanded ? "bi-folder2-open" : "bi-folder-fill", + ) + } + + if (newExpanded && childUl.dataset.loaded !== "true") { + this.renderFileTree( + content, + childUl, + level + 1, + dirPath, + searchTermEntered, + ) + childUl.dataset.loaded = "true" + } + }) + } + + if (tree.files && tree.files.length > 0) { + for (const file of tree.files) { + const filePath = this.getRelativePath(file, currentPath) + const isSelected = this.selectedFiles.has(file.id) + const isExistingFile = + this.isEditMode && + this.formHandler?.currentFiles?.has(file.id) + + const li = document.createElement("li") + li.className = "file-item" + li.dataset.fileId = file.id + + const rowSpan = document.createElement("span") + rowSpan.className = "file-browser-row" + rowSpan.setAttribute("role", "option") + rowSpan.setAttribute( + "aria-selected", + isSelected ? "true" : "false", + ) + rowSpan.setAttribute("tabindex", "0") + rowSpan.innerHTML = ` ${file.name} - `; - - li.appendChild(rowSpan); - const checkbox = rowSpan.querySelector('input[type="checkbox"]'); - - if (!isExistingFile) { - const syncRowSelectionVisual = () => { - li.classList.toggle("is-selected", checkbox.checked); - rowSpan.setAttribute( - "aria-selected", - checkbox.checked ? "true" : "false", - ); - }; - - if (isSelected) { - li.classList.add("is-selected"); - } - - checkbox.addEventListener("change", (e) => { - e.stopPropagation(); - if (checkbox.checked) { - this.selectedFiles.set(file.id, { - ...file, - relative_path: filePath, - }); - } else { - this.selectedFiles.delete(file.id); - } - syncRowSelectionVisual(); - this.updateSelectAllCheckboxState(); - this.updateSelectedFilesList(); - }); - - const toggleRowSelection = () => { - checkbox.checked = !checkbox.checked; - checkbox.dispatchEvent(new Event("change")); - }; - - rowSpan.addEventListener("click", (e) => { - if (e.target.type === "checkbox") { - return; - } - toggleRowSelection(); - }); - - rowSpan.addEventListener("keydown", (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - toggleRowSelection(); - } - }); - - li.classList.add("clickable-row"); - } else { - li.classList.add("readonly-row"); - li.title = "This file is already in the dataset"; - } - - targetElement.appendChild(li); - } - } - - this.updateSelectAllCheckboxState(); - } - - /** - * Update files table - * @param {Object} data - Files data - */ - updateFilesTable(data) { - const root = this.getFileTreeRoot(); - if (!root) { - console.error("File tree root not found"); - return; - } - root.innerHTML = ""; - - if (!data.tree) { - this.renderEmptyFileTree(root); - return; - } + ` + + li.appendChild(rowSpan) + const checkbox = rowSpan.querySelector('input[type="checkbox"]') + + if (!isExistingFile) { + const syncRowSelectionVisual = () => { + li.classList.toggle("is-selected", checkbox.checked) + rowSpan.setAttribute( + "aria-selected", + checkbox.checked ? "true" : "false", + ) + } + + if (isSelected) { + li.classList.add("is-selected") + } + + checkbox.addEventListener("change", (e) => { + e.stopPropagation() + if (checkbox.checked) { + this.selectedFiles.set(file.id, { + ...file, + relative_path: filePath, + }) + } else { + this.selectedFiles.delete(file.id) + } + syncRowSelectionVisual() + this.updateSelectAllCheckboxState() + this.updateSelectedFilesList() + }) + + const toggleRowSelection = () => { + checkbox.checked = !checkbox.checked + checkbox.dispatchEvent(new Event("change")) + } + + rowSpan.addEventListener("click", (e) => { + if (e.target.type === "checkbox") { + return + } + toggleRowSelection() + }) + + rowSpan.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + toggleRowSelection() + } + }) + + li.classList.add("clickable-row") + } else { + li.classList.add("readonly-row") + li.title = "This file is already in the dataset" + } + + targetElement.appendChild(li) + } + } + + this.updateSelectAllCheckboxState() + } + + /** + * Update files table + * @param {Object} data - Files data + */ + updateFilesTable(data) { + const root = this.getFileTreeRoot() + if (!root) { + console.error("File tree root not found") + return + } + root.innerHTML = "" + + if (!data.tree) { + this.renderEmptyFileTree(root) + return + } this.renderFileTree(data.tree) } - /** - * Render empty file tree placeholder - * @param {HTMLElement} root - File tree root element - */ - renderEmptyFileTree(root) { - root.innerHTML = - '
  • No files or directories found
  • '; - } + /** + * Render empty file tree placeholder + * @param {HTMLElement} root - File tree root element + */ + renderEmptyFileTree(root) { + root.innerHTML = + '
  • No files or directories found
  • ' + } /** * Show error message @@ -1549,15 +1574,20 @@ class AssetSearchHandler { ) if (!selectAllCheckbox) return - const fileCheckboxes = this.getVisibleFileCheckboxes(); - const checkedBoxes = fileCheckboxes.filter((checkbox) => checkbox.checked); + const fileCheckboxes = this.getVisibleFileCheckboxes() + const checkedBoxes = fileCheckboxes.filter( + (checkbox) => checkbox.checked, + ) - if (checkedBoxes.length === fileCheckboxes.length && fileCheckboxes.length > 0) { - selectAllCheckbox.checked = true; - } else { - selectAllCheckbox.checked = false; - } - } + if ( + checkedBoxes.length === fileCheckboxes.length && + fileCheckboxes.length > 0 + ) { + selectAllCheckbox.checked = true + } else { + selectAllCheckbox.checked = false + } + } /** * Update results count diff --git a/gateway/sds_gateway/static/js/search/__tests__/AssetSearchHandler.test.js b/gateway/sds_gateway/static/js/search/__tests__/AssetSearchHandler.test.js index 1d2e1eb42..6ab0b46bd 100644 --- a/gateway/sds_gateway/static/js/search/__tests__/AssetSearchHandler.test.js +++ b/gateway/sds_gateway/static/js/search/__tests__/AssetSearchHandler.test.js @@ -16,432 +16,438 @@ const { } = require("../../tests-config/testHelpers.js") describe("AssetSearchHandler", () => { - let searchHandler; - let mockConfig; - let mockForm; - let mockButton; - let mockTableBody; - - beforeEach(() => { - mockForm = createMockFormElement(); - mockButton = createMockButtonElement(); - mockTableBody = { - innerHTML: "", - querySelectorAll: jest.fn(() => []), - }; - - const defaultSearchResponse = { - success: true, - results: [ - { id: 1, name: "Test Capture 1", type: "rh" }, - { id: 2, name: "Test Capture 2", type: "drf" }, - ], - pagination: { - current_page: 1, - total_pages: 1, - has_next: false, - has_previous: false, - }, - }; - - setupStandardUnitTest({ - getElementByIdMap: createAssetSearchGetElementByIdMap({ - mockForm, - mockButton, - mockTableBody, - }), - apiClientOverrides: { - request: jest.fn().mockResolvedValue(defaultSearchResponse), - }, - }); - - mockConfig = createDefaultAssetSearchConfig(); - - document.querySelector = jest.fn((selector) => { - if (selector === "#captures-table tbody") { - return mockTableBody; - } - return null; - }); - - mergeWindowMocks({ - datasetEditingHandler: { - addFileToPending: jest.fn(), - }, - }); - }); - - describe("Initialization", () => { - test("should initialize with correct configuration", () => { - searchHandler = new AssetSearchHandler(mockConfig); - - expect(searchHandler.searchForm).toBe(mockForm); - expect(searchHandler.searchButton).toBe(mockButton); - expect(searchHandler.clearButton).toBe(mockButton); - expect(searchHandler.tableBody).toBe(mockTableBody); - expect(searchHandler.type).toBe("captures"); - expect(searchHandler.isEditMode).toBe(false); - expect(searchHandler.selectedFiles).toBeInstanceOf(Map); - expect(searchHandler.selectedCaptureDetails).toBeInstanceOf(Map); - }); - - test("should setup form handler reference", () => { - searchHandler = new AssetSearchHandler(mockConfig); - - expect(mockConfig.formHandler.setSearchHandler).toHaveBeenCalledWith( - searchHandler, - "captures", - ); - }); - - test("should initialize with initial data", () => { - const configWithInitialData = { - ...mockConfig, - initialFileDetails: { "file1-uuid": { name: "test.h5" } }, - initialCaptureDetails: { "capture1-uuid": { name: "test capture" } }, - }; - - searchHandler = new AssetSearchHandler(configWithInitialData); - - expect(searchHandler.selectedFiles.has("file1-uuid")).toBe(true); - expect(searchHandler.selectedCaptureDetails.has("capture1-uuid")).toBe( - true, - ); - }); - }); - - describe("Event Listeners", () => { - beforeEach(() => { - searchHandler = new AssetSearchHandler(mockConfig); - }); - - test.each([ - ["search button"], - ["clear button"], - ["confirm file selection"], - ])("should setup %s event listener", (buttonName) => { - expect(mockButton.addEventListener).toHaveBeenCalledWith( - "click", - expect.any(Function), - ); - }); - - test("should setup enter key listener on search inputs", () => { - const mockInput = { - addEventListener: jest.fn(), - }; - mockForm.querySelectorAll.mockReturnValue([mockInput]); - - searchHandler.initializeEnterKeyListener(); - - expect(mockInput.addEventListener).toHaveBeenCalledWith( - "keypress", - expect.any(Function), - ); - }); - }); - - describe("Clear Functionality", () => { - beforeEach(() => { - searchHandler = new AssetSearchHandler(mockConfig); - }); - - test("should clear search results", () => { - searchHandler.handleClear(); - - expect(mockTableBody.innerHTML).toBe(""); - }); - - test("should clear search form", () => { - const mockInput = { value: "test search" }; - mockForm.querySelectorAll.mockReturnValue([mockInput]); - - searchHandler.handleClear(); - - expect(mockInput.value).toBe(""); - }); - }); - - describe("Selection Properties", () => { - beforeEach(() => { - searchHandler = new AssetSearchHandler(mockConfig); - }); - - test.each([ - ["selectedFiles", Map], - ["selectedCaptureDetails", Map], - ])("should have %s property", (propertyName, expectedType) => { - expect(searchHandler[propertyName]).toBeDefined(); - expect(searchHandler[propertyName]).toBeInstanceOf(expectedType); - }); - - test("should update selected files list", () => { - searchHandler.updateSelectedFilesList(); - expect(searchHandler.updateSelectedFilesList).toBeDefined(); - }); - }); - - describe("Select All Functionality", () => { - beforeEach(() => { - searchHandler = new AssetSearchHandler(mockConfig); - }); - - test("should initialize select all checkbox", () => { - const mockCheckbox = { - addEventListener: jest.fn(), - checked: false, - }; - document.getElementById.mockImplementation((id) => { - if (id === "select-all-files-checkbox") return mockCheckbox; - return null; - }); - - searchHandler.initializeSelectAllCheckbox(); - - expect(mockCheckbox.addEventListener).toHaveBeenCalledWith( - "change", - expect.any(Function), - ); - }); - }); - - describe("Edit Mode Integration", () => { - beforeEach(() => { - const editConfig = { - ...mockConfig, - isEditMode: true, - }; - searchHandler = new AssetSearchHandler(editConfig); - }); - - test("should initialize in edit mode", () => { - expect(searchHandler.isEditMode).toBe(true); - }); - }); - - describe("Error Handling", () => { - beforeEach(() => { - searchHandler = new AssetSearchHandler(mockConfig); - }); - - test("should handle missing form handler gracefully", () => { - const configWithoutFormHandler = { - ...mockConfig, - formHandler: null, - }; - - expect(() => { - new AssetSearchHandler(configWithoutFormHandler); - }).not.toThrow(); - }); - }); - - describe("State Management", () => { - beforeEach(() => { - searchHandler = new AssetSearchHandler(mockConfig); - }); - - test("should track current filters", () => { - searchHandler.currentFilters = { type: "spectrum" }; - - expect(searchHandler.currentFilters.type).toBe("spectrum"); - }); - }); - - describe("Checkbox Disabling for Existing Files", () => { - let editModeHandler; - let createModeHandler; - let mockTargetElement; - - beforeEach(() => { - // Setup edit mode handler with existing files - const editConfig = { - ...mockConfig, - isEditMode: true, - formHandler: { - setSearchHandler: jest.fn(), - currentFiles: new Map([ - ["existing-file-1", { id: "existing-file-1", name: "existing.h5" }], - ]), - }, - }; - editModeHandler = new AssetSearchHandler(editConfig); - - // Setup create mode handler - const createConfig = { - ...mockConfig, - isEditMode: false, - }; - createModeHandler = new AssetSearchHandler(createConfig); - - // Mock target element for rendering - mockTargetElement = document.createElement("ul"); - mockTargetElement.id = "file-tree-root"; - - // Mock DOMUtils.formatFileSize - global.window.DOMUtils.formatFileSize = jest.fn((size) => size || "0 B"); - }); - - test("should disable checkbox and add readonly styling for existing files in edit mode", () => { - const existingFile = { - id: "existing-file-1", - name: "existing.h5", - media_type: "application/hdf5", - size: 1024, - created_at: "2024-01-01T00:00:00Z", - }; - - const tree = { - files: [existingFile], - }; - - editModeHandler.renderFileTree(tree, mockTargetElement); - - const row = mockTargetElement.querySelector("li.file-item"); - const checkbox = row.querySelector('input[type="checkbox"]'); - - // Check that checkbox is disabled - expect(checkbox.disabled).toBe(true); - // Check that readonly-row class is added - expect(row.classList.contains("readonly-row")).toBe(true); - // Check that tooltip is set - expect(row.title).toBe("This file is already in the dataset"); - // Check that clickable-row class is NOT added - expect(row.classList.contains("clickable-row")).toBe(false); - }); - - test("should enable checkbox and add event handlers for new files in edit mode", () => { - const newFile = { - id: "new-file-1", - name: "new.h5", - media_type: "application/hdf5", - size: 2048, - created_at: "2024-01-02T00:00:00Z", - }; - - const tree = { - files: [newFile], - }; - - // Spy on addEventListener to verify event handlers are attached - const addEventListenerSpy = jest.spyOn( - HTMLElement.prototype, - "addEventListener", - ); - - editModeHandler.renderFileTree(tree, mockTargetElement); - - const row = mockTargetElement.querySelector("li.file-item"); - const checkbox = row.querySelector('input[type="checkbox"]'); - - // Check that checkbox is NOT disabled - expect(checkbox.disabled).toBe(false); - // Check that clickable-row class is added - expect(row.classList.contains("clickable-row")).toBe(true); - // Check that readonly-row class is NOT added - expect(row.classList.contains("readonly-row")).toBe(false); - // Verify event handlers were attached (change event for checkbox, click for row) - expect(addEventListenerSpy).toHaveBeenCalledWith( - "change", - expect.any(Function), - ); - expect(addEventListenerSpy).toHaveBeenCalledWith( - "click", - expect.any(Function), - ); - - addEventListenerSpy.mockRestore(); - }); - - test("should enable all checkboxes in create mode regardless of file existence", () => { - const file = { - id: "any-file-1", - name: "any.h5", - media_type: "application/hdf5", - size: 1024, - created_at: "2024-01-01T00:00:00Z", - }; - - const tree = { - files: [file], - }; - - createModeHandler.renderFileTree(tree, mockTargetElement); - - const row = mockTargetElement.querySelector("li.file-item"); - const checkbox = row.querySelector('input[type="checkbox"]'); - - // In create mode, all checkboxes should be enabled - expect(checkbox.disabled).toBe(false); - expect(row.classList.contains("readonly-row")).toBe(false); - expect(row.classList.contains("clickable-row")).toBe(true); - }); - - test("should handle missing formHandler gracefully when rendering", () => { - const configWithoutFormHandler = { - ...mockConfig, - isEditMode: true, - formHandler: null, - }; - const handler = new AssetSearchHandler(configWithoutFormHandler); - - const file = { - id: "any-file-1", - name: "any.h5", - media_type: "application/hdf5", - size: 1024, - created_at: "2024-01-01T00:00:00Z", - }; - - const tree = { - files: [file], - }; - - // Should not throw when formHandler is null - expect(() => { - handler.renderFileTree(tree, mockTargetElement); - }).not.toThrow(); - - // File should be enabled since formHandler is null - const checkbox = mockTargetElement.querySelector( - 'input[type="checkbox"]', - ); - expect(checkbox.disabled).toBe(false); - }); - - test("should handle missing currentFiles gracefully when rendering", () => { - const configWithoutCurrentFiles = { - ...mockConfig, - isEditMode: true, - formHandler: { - setSearchHandler: jest.fn(), - currentFiles: null, - }, - }; - const handler = new AssetSearchHandler(configWithoutCurrentFiles); - - const file = { - id: "any-file-1", - name: "any.h5", - media_type: "application/hdf5", - size: 1024, - created_at: "2024-01-01T00:00:00Z", - }; - - const tree = { - files: [file], - }; - - // Should not throw when currentFiles is null - expect(() => { - handler.renderFileTree(tree, mockTargetElement); - }).not.toThrow(); - - // File should be enabled since currentFiles is null - const checkbox = mockTargetElement.querySelector( - 'input[type="checkbox"]', - ); - expect(checkbox.disabled).toBe(false); - }); - }); -}); + let searchHandler + let mockConfig + let mockForm + let mockButton + let mockTableBody + + beforeEach(() => { + mockForm = createMockFormElement() + mockButton = createMockButtonElement() + mockTableBody = { + innerHTML: "", + querySelectorAll: jest.fn(() => []), + } + + const defaultSearchResponse = { + success: true, + results: [ + { id: 1, name: "Test Capture 1", type: "rh" }, + { id: 2, name: "Test Capture 2", type: "drf" }, + ], + pagination: { + current_page: 1, + total_pages: 1, + has_next: false, + has_previous: false, + }, + } + + setupStandardUnitTest({ + getElementByIdMap: createAssetSearchGetElementByIdMap({ + mockForm, + mockButton, + mockTableBody, + }), + apiClientOverrides: { + request: jest.fn().mockResolvedValue(defaultSearchResponse), + }, + }) + + mockConfig = createDefaultAssetSearchConfig() + + document.querySelector = jest.fn((selector) => { + if (selector === "#captures-table tbody") { + return mockTableBody + } + return null + }) + + mergeWindowMocks({ + datasetEditingHandler: { + addFileToPending: jest.fn(), + }, + }) + }) + + describe("Initialization", () => { + test("should initialize with correct configuration", () => { + searchHandler = new AssetSearchHandler(mockConfig) + + expect(searchHandler.searchForm).toBe(mockForm) + expect(searchHandler.searchButton).toBe(mockButton) + expect(searchHandler.clearButton).toBe(mockButton) + expect(searchHandler.tableBody).toBe(mockTableBody) + expect(searchHandler.type).toBe("captures") + expect(searchHandler.isEditMode).toBe(false) + expect(searchHandler.selectedFiles).toBeInstanceOf(Map) + expect(searchHandler.selectedCaptureDetails).toBeInstanceOf(Map) + }) + + test("should setup form handler reference", () => { + searchHandler = new AssetSearchHandler(mockConfig) + + expect( + mockConfig.formHandler.setSearchHandler, + ).toHaveBeenCalledWith(searchHandler, "captures") + }) + + test("should initialize with initial data", () => { + const configWithInitialData = { + ...mockConfig, + initialFileDetails: { "file1-uuid": { name: "test.h5" } }, + initialCaptureDetails: { + "capture1-uuid": { name: "test capture" }, + }, + } + + searchHandler = new AssetSearchHandler(configWithInitialData) + + expect(searchHandler.selectedFiles.has("file1-uuid")).toBe(true) + expect( + searchHandler.selectedCaptureDetails.has("capture1-uuid"), + ).toBe(true) + }) + }) + + describe("Event Listeners", () => { + beforeEach(() => { + searchHandler = new AssetSearchHandler(mockConfig) + }) + + test.each([ + ["search button"], + ["clear button"], + ["confirm file selection"], + ])("should setup %s event listener", (buttonName) => { + expect(mockButton.addEventListener).toHaveBeenCalledWith( + "click", + expect.any(Function), + ) + }) + + test("should setup enter key listener on search inputs", () => { + const mockInput = { + addEventListener: jest.fn(), + } + mockForm.querySelectorAll.mockReturnValue([mockInput]) + + searchHandler.initializeEnterKeyListener() + + expect(mockInput.addEventListener).toHaveBeenCalledWith( + "keypress", + expect.any(Function), + ) + }) + }) + + describe("Clear Functionality", () => { + beforeEach(() => { + searchHandler = new AssetSearchHandler(mockConfig) + }) + + test("should clear search results", () => { + searchHandler.handleClear() + + expect(mockTableBody.innerHTML).toBe("") + }) + + test("should clear search form", () => { + const mockInput = { value: "test search" } + mockForm.querySelectorAll.mockReturnValue([mockInput]) + + searchHandler.handleClear() + + expect(mockInput.value).toBe("") + }) + }) + + describe("Selection Properties", () => { + beforeEach(() => { + searchHandler = new AssetSearchHandler(mockConfig) + }) + + test.each([ + ["selectedFiles", Map], + ["selectedCaptureDetails", Map], + ])("should have %s property", (propertyName, expectedType) => { + expect(searchHandler[propertyName]).toBeDefined() + expect(searchHandler[propertyName]).toBeInstanceOf(expectedType) + }) + + test("should update selected files list", () => { + searchHandler.updateSelectedFilesList() + expect(searchHandler.updateSelectedFilesList).toBeDefined() + }) + }) + + describe("Select All Functionality", () => { + beforeEach(() => { + searchHandler = new AssetSearchHandler(mockConfig) + }) + + test("should initialize select all checkbox", () => { + const mockCheckbox = { + addEventListener: jest.fn(), + checked: false, + } + document.getElementById.mockImplementation((id) => { + if (id === "select-all-files-checkbox") return mockCheckbox + return null + }) + + searchHandler.initializeSelectAllCheckbox() + + expect(mockCheckbox.addEventListener).toHaveBeenCalledWith( + "change", + expect.any(Function), + ) + }) + }) + + describe("Edit Mode Integration", () => { + beforeEach(() => { + const editConfig = { + ...mockConfig, + isEditMode: true, + } + searchHandler = new AssetSearchHandler(editConfig) + }) + + test("should initialize in edit mode", () => { + expect(searchHandler.isEditMode).toBe(true) + }) + }) + + describe("Error Handling", () => { + beforeEach(() => { + searchHandler = new AssetSearchHandler(mockConfig) + }) + + test("should handle missing form handler gracefully", () => { + const configWithoutFormHandler = { + ...mockConfig, + formHandler: null, + } + + expect(() => { + new AssetSearchHandler(configWithoutFormHandler) + }).not.toThrow() + }) + }) + + describe("State Management", () => { + beforeEach(() => { + searchHandler = new AssetSearchHandler(mockConfig) + }) + + test("should track current filters", () => { + searchHandler.currentFilters = { type: "spectrum" } + + expect(searchHandler.currentFilters.type).toBe("spectrum") + }) + }) + + describe("Checkbox Disabling for Existing Files", () => { + let editModeHandler + let createModeHandler + let mockTargetElement + + beforeEach(() => { + // Setup edit mode handler with existing files + const editConfig = { + ...mockConfig, + isEditMode: true, + formHandler: { + setSearchHandler: jest.fn(), + currentFiles: new Map([ + [ + "existing-file-1", + { id: "existing-file-1", name: "existing.h5" }, + ], + ]), + }, + } + editModeHandler = new AssetSearchHandler(editConfig) + + // Setup create mode handler + const createConfig = { + ...mockConfig, + isEditMode: false, + } + createModeHandler = new AssetSearchHandler(createConfig) + + // Mock target element for rendering + mockTargetElement = document.createElement("ul") + mockTargetElement.id = "file-tree-root" + + // Mock DOMUtils.formatFileSize + global.window.DOMUtils.formatFileSize = jest.fn( + (size) => size || "0 B", + ) + }) + + test("should disable checkbox and add readonly styling for existing files in edit mode", () => { + const existingFile = { + id: "existing-file-1", + name: "existing.h5", + media_type: "application/hdf5", + size: 1024, + created_at: "2024-01-01T00:00:00Z", + } + + const tree = { + files: [existingFile], + } + + editModeHandler.renderFileTree(tree, mockTargetElement) + + const row = mockTargetElement.querySelector("li.file-item") + const checkbox = row.querySelector('input[type="checkbox"]') + + // Check that checkbox is disabled + expect(checkbox.disabled).toBe(true) + // Check that readonly-row class is added + expect(row.classList.contains("readonly-row")).toBe(true) + // Check that tooltip is set + expect(row.title).toBe("This file is already in the dataset") + // Check that clickable-row class is NOT added + expect(row.classList.contains("clickable-row")).toBe(false) + }) + + test("should enable checkbox and add event handlers for new files in edit mode", () => { + const newFile = { + id: "new-file-1", + name: "new.h5", + media_type: "application/hdf5", + size: 2048, + created_at: "2024-01-02T00:00:00Z", + } + + const tree = { + files: [newFile], + } + + // Spy on addEventListener to verify event handlers are attached + const addEventListenerSpy = jest.spyOn( + HTMLElement.prototype, + "addEventListener", + ) + + editModeHandler.renderFileTree(tree, mockTargetElement) + + const row = mockTargetElement.querySelector("li.file-item") + const checkbox = row.querySelector('input[type="checkbox"]') + + // Check that checkbox is NOT disabled + expect(checkbox.disabled).toBe(false) + // Check that clickable-row class is added + expect(row.classList.contains("clickable-row")).toBe(true) + // Check that readonly-row class is NOT added + expect(row.classList.contains("readonly-row")).toBe(false) + // Verify event handlers were attached (change event for checkbox, click for row) + expect(addEventListenerSpy).toHaveBeenCalledWith( + "change", + expect.any(Function), + ) + expect(addEventListenerSpy).toHaveBeenCalledWith( + "click", + expect.any(Function), + ) + + addEventListenerSpy.mockRestore() + }) + + test("should enable all checkboxes in create mode regardless of file existence", () => { + const file = { + id: "any-file-1", + name: "any.h5", + media_type: "application/hdf5", + size: 1024, + created_at: "2024-01-01T00:00:00Z", + } + + const tree = { + files: [file], + } + + createModeHandler.renderFileTree(tree, mockTargetElement) + + const row = mockTargetElement.querySelector("li.file-item") + const checkbox = row.querySelector('input[type="checkbox"]') + + // In create mode, all checkboxes should be enabled + expect(checkbox.disabled).toBe(false) + expect(row.classList.contains("readonly-row")).toBe(false) + expect(row.classList.contains("clickable-row")).toBe(true) + }) + + test("should handle missing formHandler gracefully when rendering", () => { + const configWithoutFormHandler = { + ...mockConfig, + isEditMode: true, + formHandler: null, + } + const handler = new AssetSearchHandler(configWithoutFormHandler) + + const file = { + id: "any-file-1", + name: "any.h5", + media_type: "application/hdf5", + size: 1024, + created_at: "2024-01-01T00:00:00Z", + } + + const tree = { + files: [file], + } + + // Should not throw when formHandler is null + expect(() => { + handler.renderFileTree(tree, mockTargetElement) + }).not.toThrow() + + // File should be enabled since formHandler is null + const checkbox = mockTargetElement.querySelector( + 'input[type="checkbox"]', + ) + expect(checkbox.disabled).toBe(false) + }) + + test("should handle missing currentFiles gracefully when rendering", () => { + const configWithoutCurrentFiles = { + ...mockConfig, + isEditMode: true, + formHandler: { + setSearchHandler: jest.fn(), + currentFiles: null, + }, + } + const handler = new AssetSearchHandler(configWithoutCurrentFiles) + + const file = { + id: "any-file-1", + name: "any.h5", + media_type: "application/hdf5", + size: 1024, + created_at: "2024-01-01T00:00:00Z", + } + + const tree = { + files: [file], + } + + // Should not throw when currentFiles is null + expect(() => { + handler.renderFileTree(tree, mockTargetElement) + }).not.toThrow() + + // File should be enabled since currentFiles is null + const checkbox = mockTargetElement.querySelector( + 'input[type="checkbox"]', + ) + expect(checkbox.disabled).toBe(false) + }) + }) +}) diff --git a/gateway/sds_gateway/templates/users/partials/file_browser.html b/gateway/sds_gateway/templates/users/partials/file_browser.html index b221abf72..545c3bafe 100644 --- a/gateway/sds_gateway/templates/users/partials/file_browser.html +++ b/gateway/sds_gateway/templates/users/partials/file_browser.html @@ -108,9 +108,7 @@
    Browse and select files
    -
      +
      • Use the search form above to browse files From 57843fb511c547bb7345a9a9d0b348d863c525f8 Mon Sep 17 00:00:00 2001 From: klpoland Date: Wed, 10 Jun 2026 16:33:11 -0400 Subject: [PATCH 04/11] add folder selection mode --- .../static/css/spectrumx_theme.css | 28 ++ .../static/js/search/AssetSearchHandler.js | 370 ++++++++++++++++-- .../users/partials/file_browser.html | 29 +- 3 files changed, 399 insertions(+), 28 deletions(-) diff --git a/gateway/sds_gateway/static/css/spectrumx_theme.css b/gateway/sds_gateway/static/css/spectrumx_theme.css index dcd38d467..645236f22 100644 --- a/gateway/sds_gateway/static/css/spectrumx_theme.css +++ b/gateway/sds_gateway/static/css/spectrumx_theme.css @@ -308,6 +308,34 @@ textarea:focus-visible { background-color: #e9ecef; } +.file-browser-modal .folder-item.is-selected > .file-browser-row { + background-color: rgba(0, 90, 156, 0.16); + color: #003d6b; + font-weight: 500; +} + +.file-browser-modal .folder-expand-toggle { + display: none; + flex-shrink: 0; + line-height: 1; + color: inherit; + text-decoration: none; +} + +.file-browser-modal.folder-selection-mode-active .folder-expand-toggle { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.file-browser-modal .folder-expand-icon { + transition: transform 0.15s ease; +} + +.file-browser-modal .folder-expand-icon.folder-expand-icon-open { + transform: rotate(90deg); +} + .file-browser-modal .file-item.readonly-row > .file-browser-row { cursor: not-allowed; opacity: 0.65; diff --git a/gateway/sds_gateway/static/js/search/AssetSearchHandler.js b/gateway/sds_gateway/static/js/search/AssetSearchHandler.js index 39c89dbee..e078acee8 100644 --- a/gateway/sds_gateway/static/js/search/AssetSearchHandler.js +++ b/gateway/sds_gateway/static/js/search/AssetSearchHandler.js @@ -311,8 +311,12 @@ class AssetSearchHandler { } this._coreSearchListenersBound = false + this.folderSelectionMode = false this.initializeEventListeners() + if (this.type === "files") { + this.initializeFolderSelectionControls() + } } /** @@ -420,6 +424,290 @@ class AssetSearchHandler { }) } + /** + * Bind folder selection mode toggle and modal clear selections control. + */ + initializeFolderSelectionControls() { + const modeSwitch = document.getElementById( + "folder-selection-mode-switch", + ) + if (modeSwitch && modeSwitch.dataset?.folderModeBound !== "true") { + if (modeSwitch.dataset) { + modeSwitch.dataset.folderModeBound = "true" + } + modeSwitch.addEventListener("change", () => { + this.setFolderSelectionMode(modeSwitch.checked) + }) + } + + const clearButton = document.getElementById( + "clear-modal-file-selections", + ) + if (clearButton && clearButton.dataset?.clearModalBound !== "true") { + if (clearButton.dataset) { + clearButton.dataset.clearModalBound = "true" + } + clearButton.addEventListener("click", (e) => { + e.preventDefault() + this.clearModalFileSelections() + }) + } + + const modal = document.getElementById("fileTreeModal") + if (modal && modal.dataset?.folderModeModalBound !== "true") { + if (modal.dataset) { + modal.dataset.folderModeModalBound = "true" + } + modal.addEventListener("hidden.bs.modal", () => { + this.setFolderSelectionMode(false) + }) + } + } + + /** + * @param {boolean} enabled + */ + setFolderSelectionMode(enabled) { + this.folderSelectionMode = enabled + const modeSwitch = document.getElementById( + "folder-selection-mode-switch", + ) + if (modeSwitch && modeSwitch.checked !== enabled) { + modeSwitch.checked = enabled + } + this.updateFolderSelectionModeUI() + } + + updateFolderSelectionModeUI() { + const browser = document.querySelector(".file-browser-modal") + const info = browser?.querySelector(".selection-info") + if (browser) { + browser.classList.toggle( + "folder-selection-mode-active", + this.folderSelectionMode, + ) + } + if (info) { + info.textContent = this.folderSelectionMode + ? "Click a folder to select all files inside it (use ▶ to expand)" + : "Browse and select files" + } + } + + /** + * @param {string} fileId + * @returns {boolean} + */ + isFileExistingOnDataset(fileId) { + return ( + this.isEditMode && + Boolean(this.formHandler?.currentFiles?.has(fileId)) + ) + } + + /** + * @param {Object} node - Directory tree node + * @param {string} currentPath + * @returns {{ file: Object, relative_path: string }[]} + */ + getSelectableFilesInDirectoryNode(node, currentPath) { + const collected = [] + for (const file of node.files || []) { + if (this.isFileExistingOnDataset(file.id)) { + continue + } + collected.push({ + file, + relative_path: this.getRelativePath(file, currentPath), + }) + } + for (const child of Object.values(node.children || {})) { + if (child?.type !== "directory") { + continue + } + const childPath = currentPath + ? `${currentPath}/${child.name}` + : child.name + collected.push( + ...this.getSelectableFilesInDirectoryNode(child, childPath), + ) + } + return collected + } + + /** + * @param {string} fileId + * @param {boolean} checked + */ + syncFileCheckboxVisual(fileId, checked) { + const checkbox = document.querySelector( + `#file-tree-root input[name="files"][value="${fileId}"]`, + ) + if (!checkbox || checkbox.disabled) { + return + } + checkbox.checked = checked + const fileLi = checkbox.closest(".file-item") + const rowSpan = checkbox.closest(".file-browser-row") + fileLi?.classList.toggle("is-selected", checked) + if (rowSpan) { + rowSpan.setAttribute("aria-selected", checked ? "true" : "false") + } + } + + /** + * @param {Object} content - Directory node + * @param {string} dirPath + * @param {HTMLElement} folderLi + */ + toggleDirectorySelection(content, dirPath, folderLi) { + const entries = this.getSelectableFilesInDirectoryNode(content, dirPath) + if (entries.length === 0) { + return + } + + const allSelected = entries.every(({ file }) => + this.selectedFiles.has(file.id), + ) + + for (const { file, relative_path } of entries) { + if (allSelected) { + this.selectedFiles.delete(file.id) + this.syncFileCheckboxVisual(file.id, false) + } else { + this.selectedFiles.set(file.id, { + ...file, + relative_path, + }) + this.syncFileCheckboxVisual(file.id, true) + } + } + + this.syncFolderSelectionVisual(folderLi, content, dirPath) + this.updateSelectAllCheckboxState() + this.updateSelectedFilesList() + } + + /** + * @param {HTMLElement} folderLi + * @param {Object} content + * @param {string} dirPath + */ + syncFolderSelectionVisual(folderLi, content, dirPath) { + const entries = this.getSelectableFilesInDirectoryNode(content, dirPath) + if (entries.length === 0) { + folderLi.classList.remove("is-selected") + return + } + const allSelected = entries.every(({ file }) => + this.selectedFiles.has(file.id), + ) + folderLi.classList.toggle("is-selected", allSelected) + } + + clearModalFileSelections() { + for (const fileId of [...this.selectedFiles.keys()]) { + if (this.isFileExistingOnDataset(fileId)) { + continue + } + this.selectedFiles.delete(fileId) + } + + for (const checkbox of document.querySelectorAll( + AssetSearchHandler.FILE_TREE_FILE_CHECKBOX_SELECTOR, + )) { + if (checkbox.disabled) { + continue + } + checkbox.checked = false + const fileLi = checkbox.closest(".file-item") + fileLi?.classList.remove("is-selected") + checkbox + .closest(".file-browser-row") + ?.setAttribute("aria-selected", "false") + } + + for (const folderLi of document.querySelectorAll( + "#file-tree-root .folder-item", + )) { + folderLi.classList.remove("is-selected") + } + + this.updateSelectAllCheckboxState() + this.updateSelectedFilesList() + } + + /** + * @param {HTMLElement} rowSpan + * @param {HTMLElement} childUl + * @param {Object} content + * @param {string} dirPath + * @param {number} level + * @param {boolean} searchTermEntered + * @param {boolean} expanded + */ + setFolderExpanded( + rowSpan, + childUl, + content, + dirPath, + level, + searchTermEntered, + expanded, + ) { + rowSpan.setAttribute("aria-expanded", expanded ? "true" : "false") + + const folderIcon = rowSpan.querySelector(".item-content > .bi") + if (folderIcon) { + folderIcon.classList.remove("bi-folder-fill", "bi-folder2-open") + folderIcon.classList.add( + expanded ? "bi-folder2-open" : "bi-folder-fill", + ) + } + + const chevron = rowSpan.querySelector(".folder-expand-icon") + chevron?.classList.toggle("folder-expand-icon-open", expanded) + + if (expanded && childUl.dataset.loaded !== "true") { + this.renderFileTree( + content, + childUl, + level + 1, + dirPath, + searchTermEntered, + ) + childUl.dataset.loaded = "true" + } + } + + /** + * @param {HTMLElement} rowSpan + * @param {HTMLElement} childUl + * @param {Object} content + * @param {string} dirPath + * @param {number} level + * @param {boolean} searchTermEntered + */ + toggleFolderExpanded( + rowSpan, + childUl, + content, + dirPath, + level, + searchTermEntered, + ) { + const isExpanded = rowSpan.getAttribute("aria-expanded") === "true" + this.setFolderExpanded( + rowSpan, + childUl, + content, + dirPath, + level, + searchTermEntered, + !isExpanded, + ) + } + /** * Initialize remove all button */ @@ -1253,6 +1541,12 @@ class AssetSearchHandler { initiallyExpanded ? "true" : "false", ) rowSpan.innerHTML = ` + ${content.name || name} @@ -1277,39 +1571,71 @@ class AssetSearchHandler { childUl.dataset.loaded = "true" } + this.syncFolderSelectionVisual(li, content, dirPath) + + const expandToggle = rowSpan.querySelector(".folder-expand-toggle") + expandToggle?.addEventListener("click", (e) => { + e.preventDefault() + e.stopPropagation() + if (!expandable) { + return + } + this.toggleFolderExpanded( + rowSpan, + childUl, + content, + dirPath, + level, + searchTermEntered, + ) + }) + rowSpan.addEventListener("click", (e) => { e.preventDefault() e.stopPropagation() + if (e.target.closest(".folder-expand-toggle")) { + return + } + + if (this.folderSelectionMode) { + this.toggleDirectorySelection(content, dirPath, li) + return + } + if (!expandable) { return } - const isExpanded = - rowSpan.getAttribute("aria-expanded") === "true" - const newExpanded = !isExpanded - rowSpan.setAttribute( - "aria-expanded", - newExpanded ? "true" : "false", + this.toggleFolderExpanded( + rowSpan, + childUl, + content, + dirPath, + level, + searchTermEntered, ) + }) - const icon = rowSpan.querySelector(".bi") - if (icon) { - icon.classList.remove("bi-folder-fill", "bi-folder2-open") - icon.classList.add( - newExpanded ? "bi-folder2-open" : "bi-folder-fill", - ) + rowSpan.addEventListener("keydown", (e) => { + if (e.key !== "Enter" && e.key !== " ") { + return } - - if (newExpanded && childUl.dataset.loaded !== "true") { - this.renderFileTree( - content, - childUl, - level + 1, - dirPath, - searchTermEntered, - ) - childUl.dataset.loaded = "true" + e.preventDefault() + if (this.folderSelectionMode) { + this.toggleDirectorySelection(content, dirPath, li) + return } + if (!expandable) { + return + } + this.toggleFolderExpanded( + rowSpan, + childUl, + content, + dirPath, + level, + searchTermEntered, + ) }) } diff --git a/gateway/sds_gateway/templates/users/partials/file_browser.html b/gateway/sds_gateway/templates/users/partials/file_browser.html index 545c3bafe..d1c461c1e 100644 --- a/gateway/sds_gateway/templates/users/partials/file_browser.html +++ b/gateway/sds_gateway/templates/users/partials/file_browser.html @@ -95,13 +95,30 @@ -
        - - +
        +
        + + +
        +
        + + +
        +
        +

        + When enabled, click a folder row to select all files inside it. Use the arrow to expand subfolders. +

        From 8c77528982ae7126adc1aa9e83b2b8d0f2bf9d61 Mon Sep 17 00:00:00 2001 From: klpoland Date: Thu, 11 Jun 2026 09:37:08 -0400 Subject: [PATCH 05/11] update tree item rendering to use programmatic dom construction instead of template literals --- .../django_javascript_implementation.mdc | 1 + .../static/css/spectrumx_theme.css | 5 + .../static/js/search/AssetSearchHandler.js | 193 +++++++++--------- 3 files changed, 108 insertions(+), 91 deletions(-) diff --git a/.cursor/rules/django_javascript_implementation.mdc b/.cursor/rules/django_javascript_implementation.mdc index d4a0db04c..c5e745760 100644 --- a/.cursor/rules/django_javascript_implementation.mdc +++ b/.cursor/rules/django_javascript_implementation.mdc @@ -31,3 +31,4 @@ alwaysApply: true - HTML fragments that may change throughout the page lifecycle should be created as django template files under `components/` - Any variables within those components should be handled in the Javascript handlers/managers and passed as context objects to the Django view `RenderHTMLFragmentView` which renders the Django template files in `components/`using a context object passed into a Javascript `APIClient` call. - Never try to render HTML fragments in Javascript. +- Where appropriate, use programmatic DOM construction through the JS DOM APIs instead of HTML template literals in Javascript. All user defined inputs should be passed to the DOM as text strings (e.g. using `textContent`, `document.createTextNode()`, `String()` when setting user defined values/attributes/text). diff --git a/gateway/sds_gateway/static/css/spectrumx_theme.css b/gateway/sds_gateway/static/css/spectrumx_theme.css index 645236f22..38c578af8 100644 --- a/gateway/sds_gateway/static/css/spectrumx_theme.css +++ b/gateway/sds_gateway/static/css/spectrumx_theme.css @@ -290,6 +290,11 @@ textarea:focus-visible { box-sizing: border-box; } +.file-browser-modal [role="treeitem"]:focus-visible { + outline: 2px solid #005a9c; + outline-offset: 2px; +} + .file-browser-modal .file-item > .file-browser-row:hover { background-color: rgba(0, 90, 156, 0.08); } diff --git a/gateway/sds_gateway/static/js/search/AssetSearchHandler.js b/gateway/sds_gateway/static/js/search/AssetSearchHandler.js index e078acee8..3181e4bc1 100644 --- a/gateway/sds_gateway/static/js/search/AssetSearchHandler.js +++ b/gateway/sds_gateway/static/js/search/AssetSearchHandler.js @@ -548,11 +548,8 @@ class AssetSearchHandler { } checkbox.checked = checked const fileLi = checkbox.closest(".file-item") - const rowSpan = checkbox.closest(".file-browser-row") fileLi?.classList.toggle("is-selected", checked) - if (rowSpan) { - rowSpan.setAttribute("aria-selected", checked ? "true" : "false") - } + fileLi?.setAttribute("aria-selected", checked ? "true" : "false") } /** @@ -622,9 +619,7 @@ class AssetSearchHandler { checkbox.checked = false const fileLi = checkbox.closest(".file-item") fileLi?.classList.remove("is-selected") - checkbox - .closest(".file-browser-row") - ?.setAttribute("aria-selected", "false") + fileLi?.setAttribute("aria-selected", "false") } for (const folderLi of document.querySelectorAll( @@ -638,7 +633,7 @@ class AssetSearchHandler { } /** - * @param {HTMLElement} rowSpan + * @param {HTMLElement} folderLi * @param {HTMLElement} childUl * @param {Object} content * @param {string} dirPath @@ -647,7 +642,7 @@ class AssetSearchHandler { * @param {boolean} expanded */ setFolderExpanded( - rowSpan, + folderLi, childUl, content, dirPath, @@ -655,9 +650,10 @@ class AssetSearchHandler { searchTermEntered, expanded, ) { - rowSpan.setAttribute("aria-expanded", expanded ? "true" : "false") + folderLi.setAttribute("aria-expanded", expanded ? "true" : "false") - const folderIcon = rowSpan.querySelector(".item-content > .bi") + const rowSpan = folderLi.querySelector(".file-browser-row") + const folderIcon = rowSpan?.querySelector(".item-content > .bi") if (folderIcon) { folderIcon.classList.remove("bi-folder-fill", "bi-folder2-open") folderIcon.classList.add( @@ -665,7 +661,7 @@ class AssetSearchHandler { ) } - const chevron = rowSpan.querySelector(".folder-expand-icon") + const chevron = rowSpan?.querySelector(".folder-expand-icon") chevron?.classList.toggle("folder-expand-icon-open", expanded) if (expanded && childUl.dataset.loaded !== "true") { @@ -681,7 +677,7 @@ class AssetSearchHandler { } /** - * @param {HTMLElement} rowSpan + * @param {HTMLElement} folderLi * @param {HTMLElement} childUl * @param {Object} content * @param {string} dirPath @@ -689,16 +685,16 @@ class AssetSearchHandler { * @param {boolean} searchTermEntered */ toggleFolderExpanded( - rowSpan, + folderLi, childUl, content, dirPath, level, searchTermEntered, ) { - const isExpanded = rowSpan.getAttribute("aria-expanded") === "true" + const isExpanded = folderLi.getAttribute("aria-expanded") === "true" this.setFolderExpanded( - rowSpan, + folderLi, childUl, content, dirPath, @@ -1531,27 +1527,39 @@ class AssetSearchHandler { const li = document.createElement("li") li.className = "folder-item" + li.setAttribute("role", "treeitem") + li.setAttribute("tabindex", "0") + li.setAttribute( + "aria-expanded", + initiallyExpanded ? "true" : "false", + ) const rowSpan = document.createElement("span") rowSpan.className = "file-browser-row" - rowSpan.setAttribute("role", "button") - rowSpan.setAttribute("tabindex", "0") - rowSpan.setAttribute( - "aria-expanded", - initiallyExpanded ? "true" : "false", + + const expandToggle = document.createElement("button") + expandToggle.type = "button" + expandToggle.className = + "btn btn-link btn-sm p-0 folder-expand-toggle" + expandToggle.setAttribute("aria-label", "Expand or collapse folder") + if (!expandable) { + expandToggle.hidden = true + } + const chevronIcon = document.createElement("i") + chevronIcon.className = `bi bi-chevron-right folder-expand-icon${initiallyExpanded ? " folder-expand-icon-open" : ""}` + expandToggle.appendChild(chevronIcon) + + const itemContent = document.createElement("span") + itemContent.className = "item-content" + const folderIconEl = document.createElement("i") + folderIconEl.className = `bi ${folderIcon}` + itemContent.appendChild(folderIconEl) + itemContent.appendChild( + document.createTextNode(String(content.name || name)), ) - rowSpan.innerHTML = ` - - - - ${content.name || name} - - ` + + rowSpan.appendChild(expandToggle) + rowSpan.appendChild(itemContent) const childUl = document.createElement("ul") childUl.setAttribute("role", "group") @@ -1573,15 +1581,14 @@ class AssetSearchHandler { this.syncFolderSelectionVisual(li, content, dirPath) - const expandToggle = rowSpan.querySelector(".folder-expand-toggle") - expandToggle?.addEventListener("click", (e) => { + expandToggle.addEventListener("click", (e) => { e.preventDefault() e.stopPropagation() if (!expandable) { return } this.toggleFolderExpanded( - rowSpan, + li, childUl, content, dirPath, @@ -1590,9 +1597,16 @@ class AssetSearchHandler { ) }) - rowSpan.addEventListener("click", (e) => { - e.preventDefault() - e.stopPropagation() + const handleFolderRowActivate = (e) => { + if (e.type === "keydown") { + if (e.key !== "Enter" && e.key !== " ") { + return + } + e.preventDefault() + } else { + e.preventDefault() + e.stopPropagation() + } if (e.target.closest(".folder-expand-toggle")) { return } @@ -1607,36 +1621,17 @@ class AssetSearchHandler { } this.toggleFolderExpanded( - rowSpan, + li, childUl, content, dirPath, level, searchTermEntered, ) - }) + } - rowSpan.addEventListener("keydown", (e) => { - if (e.key !== "Enter" && e.key !== " ") { - return - } - e.preventDefault() - if (this.folderSelectionMode) { - this.toggleDirectorySelection(content, dirPath, li) - return - } - if (!expandable) { - return - } - this.toggleFolderExpanded( - rowSpan, - childUl, - content, - dirPath, - level, - searchTermEntered, - ) - }) + li.addEventListener("click", handleFolderRowActivate) + li.addEventListener("keydown", handleFolderRowActivate) } if (tree.files && tree.files.length > 0) { @@ -1649,35 +1644,49 @@ class AssetSearchHandler { const li = document.createElement("li") li.className = "file-item" - li.dataset.fileId = file.id + li.setAttribute("role", "treeitem") + li.setAttribute("aria-selected", isSelected ? "true" : "false") + li.dataset.fileId = String(file.id) const rowSpan = document.createElement("span") rowSpan.className = "file-browser-row" - rowSpan.setAttribute("role", "option") - rowSpan.setAttribute( - "aria-selected", - isSelected ? "true" : "false", - ) - rowSpan.setAttribute("tabindex", "0") - rowSpan.innerHTML = ` - - - - ${file.name} - - ` + const itemContent = document.createElement("span") + itemContent.className = "item-content" + + const checkbox = document.createElement("input") + checkbox.type = "checkbox" + checkbox.className = "form-check-input file-checkbox" + checkbox.name = "files" + checkbox.value = String(file.id) + checkbox.checked = isSelected + checkbox.setAttribute("aria-hidden", "true") + checkbox.tabIndex = -1 + if (isExistingFile) { + checkbox.disabled = true + } + + const fileIcon = document.createElement("i") + fileIcon.className = "bi bi-file-earmark" + fileIcon.setAttribute("aria-hidden", "true") + + const nameSpan = document.createElement("span") + nameSpan.className = "file-browser-name" + nameSpan.textContent = + file.name != null ? String(file.name) : "" + + itemContent.appendChild(checkbox) + itemContent.appendChild(fileIcon) + itemContent.appendChild(nameSpan) + rowSpan.appendChild(itemContent) li.appendChild(rowSpan) - const checkbox = rowSpan.querySelector('input[type="checkbox"]') if (!isExistingFile) { + li.setAttribute("tabindex", "0") + const syncRowSelectionVisual = () => { li.classList.toggle("is-selected", checkbox.checked) - rowSpan.setAttribute( + li.setAttribute( "aria-selected", checkbox.checked ? "true" : "false", ) @@ -1707,22 +1716,24 @@ class AssetSearchHandler { checkbox.dispatchEvent(new Event("change")) } - rowSpan.addEventListener("click", (e) => { - if (e.target.type === "checkbox") { + const handleFileRowActivate = (e) => { + if (e.type === "keydown") { + if (e.key !== "Enter" && e.key !== " ") { + return + } + e.preventDefault() + } else if (e.target.type === "checkbox") { return } toggleRowSelection() - }) + } - rowSpan.addEventListener("keydown", (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault() - toggleRowSelection() - } - }) + li.addEventListener("click", handleFileRowActivate) + li.addEventListener("keydown", handleFileRowActivate) li.classList.add("clickable-row") } else { + li.setAttribute("tabindex", "-1") li.classList.add("readonly-row") li.title = "This file is already in the dataset" } From 197b2d1baf600d419f7ea1cd636d3fb2d057307b Mon Sep 17 00:00:00 2001 From: klpoland Date: Thu, 11 Jun 2026 13:27:32 -0400 Subject: [PATCH 06/11] linting issue --- gateway/sds_gateway/templates/users/partials/file_browser.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gateway/sds_gateway/templates/users/partials/file_browser.html b/gateway/sds_gateway/templates/users/partials/file_browser.html index d1c461c1e..2c0ec5f8d 100644 --- a/gateway/sds_gateway/templates/users/partials/file_browser.html +++ b/gateway/sds_gateway/templates/users/partials/file_browser.html @@ -116,7 +116,8 @@ class="btn btn-sm btn-outline-secondary" id="clear-modal-file-selections">Clear selections
        -

        +

        When enabled, click a folder row to select all files inside it. Use the arrow to expand subfolders.

        From 259f55c4d7e325a9025eb8300c3628a1ac7d66a5 Mon Sep 17 00:00:00 2001 From: klpoland Date: Thu, 11 Jun 2026 14:22:36 -0400 Subject: [PATCH 07/11] update how auth is checked in dataset view test --- gateway/sds_gateway/api_methods/tests/test_dataset_endpoints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gateway/sds_gateway/api_methods/tests/test_dataset_endpoints.py b/gateway/sds_gateway/api_methods/tests/test_dataset_endpoints.py index 49cc6ec21..f618ff123 100644 --- a/gateway/sds_gateway/api_methods/tests/test_dataset_endpoints.py +++ b/gateway/sds_gateway/api_methods/tests/test_dataset_endpoints.py @@ -662,7 +662,7 @@ def test_get_dataset_files_missing_uuid(self): assert response.status_code == status.HTTP_404_NOT_FOUND def test_get_dataset_files_unauthenticated_access(self): - """Test access without API key or session.""" + """Test access without API key (no forced user on test client).""" url = reverse("api:datasets-files", kwargs={"pk": self.dataset.uuid}) unauthenticated_client = APIClient() response = unauthenticated_client.get(url) From 4a18862704789661d183fe5b1e9aa528f443bc26 Mon Sep 17 00:00:00 2001 From: klpoland Date: Wed, 24 Jun 2026 16:54:12 -0400 Subject: [PATCH 08/11] address cursor comments --- .../static/css/spectrumx_theme.css | 1 + .../js/dataset/DatasetCreationHandler.js | 51 +++++++++++++------ .../js/dataset/DatasetEditingHandler.js | 8 ++- .../static/js/search/AssetSearchHandler.js | 23 +++++++++ 4 files changed, 65 insertions(+), 18 deletions(-) diff --git a/gateway/sds_gateway/static/css/spectrumx_theme.css b/gateway/sds_gateway/static/css/spectrumx_theme.css index 38c578af8..b6ed7e40b 100644 --- a/gateway/sds_gateway/static/css/spectrumx_theme.css +++ b/gateway/sds_gateway/static/css/spectrumx_theme.css @@ -560,6 +560,7 @@ textarea:focus-visible { } /* CSS for folder expansion/collapse */ +.file-browser li[aria-expanded="true"] > ul, .file-browser [aria-expanded="true"] ~ ul { display: block; } diff --git a/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js b/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js index fb219785a..3d7a6eda9 100644 --- a/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js +++ b/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js @@ -304,17 +304,22 @@ class DatasetCreationHandler extends BaseManager { */ handleModalFileSelection(checkbox) { const fileId = checkbox.value - const row = checkbox.closest("tr") + const fileData = this.getFileDataFromRow(checkbox) - // Get file data from the row - const fileData = this.getFileDataFromRow(row) + if (!fileData) { + return + } if (checkbox.checked) { // Add to modal intermediate selection this.modalSelectedFiles.add(fileData) } else { - // Remove from modal intermediate selection - this.modalSelectedFiles.delete(fileData) + const existing = Array.from(this.modalSelectedFiles).find( + (f) => String(f.id) === String(fileId), + ) + if (existing) { + this.modalSelectedFiles.delete(existing) + } } // Update select all checkbox state @@ -322,19 +327,33 @@ class DatasetCreationHandler extends BaseManager { } /** - * Get file data from table row - * @param {Element} row - Table row element - * @returns {Object} File data object + * Get file data from a file-tree list item checkbox + * @param {Element} checkbox - File checkbox in the file tree + * @returns {Object|null} File data object */ - getFileDataFromRow(row) { - const cells = row.querySelectorAll("td") + getFileDataFromRow(checkbox) { + const fileId = checkbox.value + const fromTree = + this.filesSearchHandler?.getFileSelectionDataById?.(fileId) + if (fromTree) { + return fromTree + } + + const listItem = checkbox.closest(".file-item") + if (!listItem) { + return null + } + return { - id: row.querySelector('input[name="files"]').value, - name: cells[1]?.textContent?.trim() || "", - media_type: cells[2]?.textContent?.trim() || "", - relative_path: cells[3]?.textContent?.trim() || "", - size: cells[4]?.textContent?.trim() || "", - created_at: cells[5]?.textContent?.trim() || "", + id: fileId, + name: + listItem + .querySelector(".file-browser-name") + ?.textContent?.trim() || "", + media_type: "", + relative_path: "", + size: "", + created_at: "", } } diff --git a/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js b/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js index 19d18783d..3e46c2062 100644 --- a/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js +++ b/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js @@ -95,9 +95,13 @@ class DatasetEditingHandler extends BaseManager { e.preventDefault() }) datasetForm.addEventListener("keypress", (e) => { - if (e.key === "Enter") { - e.preventDefault() + if (e.key !== "Enter") { + return + } + if (e.target instanceof HTMLTextAreaElement) { + return } + e.preventDefault() }) } } diff --git a/gateway/sds_gateway/static/js/search/AssetSearchHandler.js b/gateway/sds_gateway/static/js/search/AssetSearchHandler.js index 3181e4bc1..83eb4902e 100644 --- a/gateway/sds_gateway/static/js/search/AssetSearchHandler.js +++ b/gateway/sds_gateway/static/js/search/AssetSearchHandler.js @@ -535,6 +535,29 @@ class AssetSearchHandler { return collected } + /** + * @param {string} fileId + * @returns {Object|null} File metadata plus relative_path for dataset selection + */ + getFileSelectionDataById(fileId) { + if (!this.currentTree || fileId == null || fileId === "") { + return null + } + const id = String(fileId) + const entries = this.getSelectableFilesInDirectoryNode( + this.currentTree, + "", + ) + const match = entries.find(({ file }) => String(file.id) === id) + if (!match) { + return null + } + return { + ...match.file, + relative_path: match.relative_path, + } + } + /** * @param {string} fileId * @param {boolean} checked From f0926b8c67f07cce1502f8d39298ed136a719d2d Mon Sep 17 00:00:00 2001 From: klpoland Date: Thu, 25 Jun 2026 09:48:10 -0400 Subject: [PATCH 09/11] refactor file selection hand-off --- .../js/dataset/DatasetCreationHandler.js | 219 +---------- .../js/dataset/DatasetEditingHandler.js | 149 +++---- .../__tests__/DatasetCreationHandler.test.js | 13 +- .../__tests__/DatasetEditingHandler.test.js | 1 + .../static/js/search/AssetSearchHandler.js | 368 +++++++++++------- 5 files changed, 331 insertions(+), 419 deletions(-) diff --git a/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js b/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js index 3d7a6eda9..9d90ed659 100644 --- a/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js +++ b/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js @@ -40,8 +40,7 @@ class DatasetCreationHandler extends BaseManager { this.selectedFiles = new Set() // Set of file objects for the main card this.selectedCaptureDetails = new Map() // Map of capture ID -> capture details - // File browser modal state (intermediate selections) - this.modalSelectedFiles = new Set() // Set of file objects for modal intermediate state + // File browser modal state lives on filesSearchHandler.selectedFiles until confirm // Search handlers this.capturesSearchHandler = null @@ -218,42 +217,7 @@ class DatasetCreationHandler extends BaseManager { * Initialize file browser modal handlers */ initializeFileBrowserModal() { - // Modal file selection handlers - document.addEventListener("change", (e) => { - if (e.target.matches('#file-tree-root input[name="files"]')) { - this.handleModalFileSelection(e.target) - } - }) - - // Select all files checkbox - const selectAllCheckbox = document.getElementById( - "select-all-files-checkbox", - ) - if (selectAllCheckbox) { - selectAllCheckbox.addEventListener("change", (e) => { - this.handleSelectAllFiles(e.target.checked) - }) - } - - // Confirm file selection button - const confirmButton = document.getElementById("confirm-file-selection") - if (confirmButton) { - confirmButton.addEventListener("click", () => { - this.confirmFileSelection() - }) - } - window.AuthorsManager?.bindFileTreeModalHandlers(this) - - // Remove all files button - const removeAllButton = document.getElementById( - "remove-all-selected-files-button", - ) - if (!removeAllButton) return - - removeAllButton.addEventListener("click", () => { - this.removeAllSelectedFiles() - }) } /** @@ -298,181 +262,46 @@ class DatasetCreationHandler extends BaseManager { void this.updateSelectedCapturesPanel() } - /** - * Handle modal file selection (intermediate state) - * @param {Element} checkbox - Checkbox element - */ - handleModalFileSelection(checkbox) { - const fileId = checkbox.value - const fileData = this.getFileDataFromRow(checkbox) - - if (!fileData) { - return - } - - if (checkbox.checked) { - // Add to modal intermediate selection - this.modalSelectedFiles.add(fileData) - } else { - const existing = Array.from(this.modalSelectedFiles).find( - (f) => String(f.id) === String(fileId), - ) - if (existing) { - this.modalSelectedFiles.delete(existing) - } - } - - // Update select all checkbox state - this.updateSelectAllCheckbox() - } - - /** - * Get file data from a file-tree list item checkbox - * @param {Element} checkbox - File checkbox in the file tree - * @returns {Object|null} File data object - */ - getFileDataFromRow(checkbox) { - const fileId = checkbox.value - const fromTree = - this.filesSearchHandler?.getFileSelectionDataById?.(fileId) - if (fromTree) { - return fromTree - } - - const listItem = checkbox.closest(".file-item") - if (!listItem) { - return null - } - - return { - id: fileId, - name: - listItem - .querySelector(".file-browser-name") - ?.textContent?.trim() || "", - media_type: "", - relative_path: "", - size: "", - created_at: "", - } - } - - /** - * Handle select all files checkbox - * @param {boolean} checked - Whether select all is checked - */ - handleSelectAllFiles(checked) { - const checkboxes = document.querySelectorAll( - '#file-tree-root input[name="files"]', - ) - for (const checkbox of checkboxes) { - checkbox.checked = checked - this.handleModalFileSelection(checkbox) - } - } - - /** - * Update select all checkbox state - */ - updateSelectAllCheckbox() { - const selectAllCheckbox = document.getElementById( - "select-all-files-checkbox", - ) - const allCheckboxes = document.querySelectorAll( - '#file-tree-root input[name="files"]', - ) - - if (selectAllCheckbox && allCheckboxes.length > 0) { - const checkedCount = Array.from(allCheckboxes).filter( - (cb) => cb.checked, - ).length - selectAllCheckbox.checked = checkedCount === allCheckboxes.length - selectAllCheckbox.indeterminate = - checkedCount > 0 && checkedCount < allCheckboxes.length - } - } - - /** - * Confirm file selection (move from modal to main selection) - */ - confirmFileSelection() { - // Add modal selections to main selection - for (const file of this.modalSelectedFiles) { - this.selectedFiles.add(file) - } - - // Clear modal selections - this.modalSelectedFiles.clear() - - // Update UI - this.updateSelectedFilesDisplay() - this.updateHiddenFields() - - // Close modal - const modal = bootstrap.Modal.getInstance( - document.getElementById("fileTreeModal"), - ) - if (modal) { - modal.hide() - } - } - /** * Handle file modal show */ onFileModalShow() { - // Trigger initial file tree loading if filesSearchHandler exists and tree hasn't been loaded - if (this.filesSearchHandler && !this.filesSearchHandler.currentTree) { + if (!this.filesSearchHandler) { + return + } + if (!this.filesSearchHandler.currentTree) { this.filesSearchHandler.handleSearch() + return } + this.filesSearchHandler.updateFilesTable({ + tree: this.filesSearchHandler.currentTree, + }) } /** * Handle file modal hide */ onFileModalHide() { - // Clear any intermediate state if needed - this.modalSelectedFiles.clear() + this.filesSearchHandler?.clearModalFileSelections() } /** * Remove all selected files */ - removeAllSelectedFiles() { - // Clear main selection + removeAllFileSelections() { this.selectedFiles.clear() + this.filesSearchHandler?.clearModalFileSelections() - // Clear modal intermediate selection - this.modalSelectedFiles.clear() - - // Update UI this.updateSelectedFilesDisplay() this.updateHiddenFields() - - // Uncheck all checkboxes in modal if it's open - const checkboxes = document.querySelectorAll( - '#file-tree-root input[name="files"]', - ) - for (const checkbox of checkboxes) { - checkbox.checked = false - } - - // Update select all checkbox - this.updateSelectAllCheckbox() } /** * Update selected files display */ updateSelectedFilesDisplay() { - const displayInput = document.getElementById("selected-files-display") - if (!displayInput) return - - const count = this.selectedFiles.size - displayInput.value = `${count} file(s) selected` - - // Update selected files table - this.updateSelectedFilesTable() + this.filesSearchHandler?.syncCommittedFileSelectionUI?.() + void this.updateSelectedFilesTable() } /** @@ -1168,37 +997,31 @@ class DatasetCreationHandler extends BaseManager { * @param {string} fileId - File ID to remove */ removeFile(fileId) { - // Remove from main selection const fileToRemove = Array.from(this.selectedFiles).find( - (f) => f.id === fileId, + (f) => String(f.id) === String(fileId), ) if (fileToRemove) { this.selectedFiles.delete(fileToRemove) } - // Remove from modal intermediate selection - const modalFileToRemove = Array.from(this.modalSelectedFiles).find( - (f) => f.id === fileId, - ) - if (modalFileToRemove) { - this.modalSelectedFiles.delete(modalFileToRemove) - } + this.filesSearchHandler?.selectedFiles?.delete(fileId) - // Update UI this.updateSelectedFilesDisplay() this.updateHiddenFields() this.updateSelectedItemsTable() + this.filesSearchHandler?.syncCommittedFileSelectionUI?.() - // Update checkbox in file tree modal if visible const checkbox = document.querySelector( `input[name="files"][value="${fileId}"]`, ) if (checkbox) { checkbox.checked = false + const fileLi = checkbox.closest(".file-item") + fileLi?.classList.remove("is-selected") + fileLi?.setAttribute("aria-selected", "false") } - // Update select all checkbox - this.updateSelectAllCheckbox() + this.filesSearchHandler?.updateSelectAllCheckboxState?.() } /** diff --git a/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js b/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js index 3e46c2062..392057ea3 100644 --- a/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js +++ b/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js @@ -120,7 +120,7 @@ class DatasetEditingHandler extends BaseManager { }) } else if (type === "files") { this.filesSearchHandler = searchHandler - this.filesSearchHandler.updateSelectedFilesList() + this.filesSearchHandler?.syncCommittedFileSelectionUI?.() } // If we have initial data and both handlers are ready, populate the data @@ -203,12 +203,7 @@ class DatasetEditingHandler extends BaseManager { // Use the existing SearchHandler to populate files for the file browser if (this.filesSearchHandler) { - if (initialFiles && initialFiles.length > 0) { - for (const file of initialFiles) { - this.filesSearchHandler.selectedFiles.set(file.id, file) - } - } - this.filesSearchHandler.updateSelectedFilesList() + this.filesSearchHandler?.syncCommittedFileSelectionUI?.() } // Add event listeners for remove buttons @@ -229,17 +224,23 @@ class DatasetEditingHandler extends BaseManager { * Handle file modal show */ onFileModalShow() { - // Trigger initial file tree loading if filesSearchHandler exists and tree hasn't been loaded - if (this.filesSearchHandler && !this.filesSearchHandler.currentTree) { + if (!this.filesSearchHandler) { + return + } + if (!this.filesSearchHandler.currentTree) { this.filesSearchHandler.handleSearch() + return } + this.filesSearchHandler.updateFilesTable({ + tree: this.filesSearchHandler.currentTree, + }) } /** * Handle file modal hide */ onFileModalHide() { - // Clear any intermediate state if needed + this.filesSearchHandler?.clearModalFileSelections() } /** @@ -538,12 +539,31 @@ class DatasetEditingHandler extends BaseManager { } } + /** + * @param {string} fileId + * @returns {Object|undefined} + */ + getCurrentFile(fileId) { + const id = String(fileId) + return this.currentFiles.get(fileId) ?? this.currentFiles.get(id) + } + + /** + * @param {string} fileId + * @returns {{ action: string, data: Object }|undefined} + */ + getPendingFileChange(fileId) { + const id = String(fileId) + return this.pendingFiles.get(fileId) ?? this.pendingFiles.get(id) + } + /** * Mark file for removal * @param {string} fileId - File ID to mark for removal */ markFileForRemoval(fileId) { - const file = this.filesSearchHandler?.selectedFiles.get(fileId) + const id = String(fileId) + const file = this.getCurrentFile(id) if (!file) { console.warn(`File ${fileId} not found for removal`) @@ -563,23 +583,15 @@ class DatasetEditingHandler extends BaseManager { return } - // Add to pending removals - this.pendingFiles.set(fileId, { + this.pendingFiles.set(id, { action: "remove", data: file, }) - // Update visual state of current files list this.updateCurrentFilesList() - // Update review display - if (window.updateReviewDatasetDisplay) { - window.updateReviewDatasetDisplay() - } - - // Also mark in the search results table if visible const searchRow = document.querySelector( - `#file-tree-root li[data-file-id="${fileId}"]`, + `#file-tree-root li[data-file-id="${id}"]`, ) if (searchRow) { searchRow.classList.add("marked-for-removal") @@ -589,7 +601,12 @@ class DatasetEditingHandler extends BaseManager { } } - this.updatePendingFilesList() + void this.updatePendingFilesList() + this.filesSearchHandler?.syncCommittedFileSelectionUI?.() + + if (window.updateReviewDatasetDisplay) { + window.updateReviewDatasetDisplay() + } } /** @@ -632,30 +649,32 @@ class DatasetEditingHandler extends BaseManager { * Add file to pending additions * @param {string} fileId - File ID * @param {Object} fileData - File data + * @param {{ refreshUi?: boolean }} [options] */ - addFileToPending(fileId, fileData) { - // Check if already in current files - if (this.currentFiles.has(fileId)) { - return // Already in dataset + addFileToPending(fileId, fileData, options = {}) { + const { refreshUi = true } = options + const id = String(fileId) + if (this.getCurrentFile(id)) { + return } - // Check if already in pending additions - if ( - this.pendingFiles.has(fileId) && - this.pendingFiles.get(fileId).action === "add" - ) { - return // Already marked for addition + const pending = this.getPendingFileChange(id) + if (pending?.action === "add") { + return } - // Add to pending additions - this.pendingFiles.set(fileId, { + this.pendingFiles.set(id, { action: "add", data: fileData, }) - this.updatePendingFilesList() + if (!refreshUi) { + return + } + + void this.updatePendingFilesList() + this.filesSearchHandler?.syncCommittedFileSelectionUI?.() - // Update review display if (window.updateReviewDatasetDisplay) { window.updateReviewDatasetDisplay() } @@ -751,24 +770,28 @@ class DatasetEditingHandler extends BaseManager { * @param {string} fileId - File ID */ cancelFileChange(fileId) { - const change = this.pendingFiles.get(fileId) + const id = String(fileId) + const change = this.getPendingFileChange(id) if (!change) return - this.pendingFiles.delete(fileId) + this.pendingFiles.delete(id) + for (const key of [fileId, id]) { + if (key !== id) { + this.pendingFiles.delete(key) + } + } if (change.action === "remove") { // Update visual state of current files list this.updateCurrentFilesList() } else if (change.action === "add") { - // Remove from SearchHandler's selectedFiles if it exists - if (this.filesSearchHandler) { - this.filesSearchHandler.selectedFiles.delete(fileId) - } + this.filesSearchHandler?.selectedFiles?.delete(fileId) + this.filesSearchHandler?.syncFileCheckboxVisual?.(fileId, false) } this.updatePendingFilesList() + this.filesSearchHandler?.syncCommittedFileSelectionUI?.() - // Update review display if (window.updateReviewDatasetDisplay) { window.updateReviewDatasetDisplay() } @@ -812,32 +835,24 @@ class DatasetEditingHandler extends BaseManager { } /** - * Handle remove all files (override for edit mode) + * Remove all current dataset files the user may mark for removal (edit mode). */ - handleRemoveAllFiles() { - // In edit mode: mark files for removal only if user has permission - if (this.filesSearchHandler?.selectedFiles) { - let removedCount = 0 - for (const [ - fileId, - file, - ] of this.filesSearchHandler.selectedFiles.entries()) { - // Only mark for removal if user has permission - if (this.permissions.canRemoveAsset(file)) { - this.markFileForRemoval(fileId) - removedCount++ - } + removeAllFileSelections() { + let removedCount = 0 + for (const [fileId, file] of this.currentFiles.entries()) { + if (this.permissions.canRemoveAsset(file)) { + this.markFileForRemoval(fileId) + removedCount++ } + } - // Disable the remove all files button if any files were marked for removal - if (removedCount > 0) { - const removeAllFilesButton = document.querySelector( - ".remove-all-selected-files-button", - ) - if (removeAllFilesButton) { - removeAllFilesButton.disabled = true - removeAllFilesButton.classList.add("disabled-element") - } + if (removedCount > 0) { + const removeAllFilesButton = document.getElementById( + "remove-all-selected-files-button", + ) + if (removeAllFilesButton) { + removeAllFilesButton.disabled = true + removeAllFilesButton.classList.add("disabled-element") } } } @@ -858,7 +873,7 @@ class DatasetEditingHandler extends BaseManager { const rows = selectedFilesBody.querySelectorAll("tr[data-file-id]") for (const row of rows) { const fileId = row.dataset.fileId - const pendingChange = this.pendingFiles.get(fileId) + const pendingChange = this.getPendingFileChange(fileId) if (pendingChange && pendingChange.action === "remove") { // Mark as pending removal diff --git a/gateway/sds_gateway/static/js/dataset/__tests__/DatasetCreationHandler.test.js b/gateway/sds_gateway/static/js/dataset/__tests__/DatasetCreationHandler.test.js index 70fb376c6..caf500d96 100644 --- a/gateway/sds_gateway/static/js/dataset/__tests__/DatasetCreationHandler.test.js +++ b/gateway/sds_gateway/static/js/dataset/__tests__/DatasetCreationHandler.test.js @@ -250,7 +250,6 @@ describe("DatasetCreationHandler", () => { expect(creationHandler.selectedCaptures).toBeInstanceOf(Set) expect(creationHandler.selectedFiles).toBeInstanceOf(Set) expect(creationHandler.selectedCaptureDetails).toBeInstanceOf(Map) - expect(creationHandler.modalSelectedFiles).toBeInstanceOf(Set) }) test("should setup event listeners", () => { @@ -604,28 +603,20 @@ describe("DatasetCreationHandler", () => { // Test that the handler can be properly cleaned up expect(() => { // Test that we can call methods without errors - creationHandler.removeAllSelectedFiles() + creationHandler.removeAllFileSelections() creationHandler.updateHiddenFields() creationHandler.clearErrors() }).not.toThrow() }) test("should clear all selected files", () => { - // Add some files first creationHandler.selectedFiles.add({ id: "file1", name: "test.h5" }) - creationHandler.modalSelectedFiles.add({ - id: "file2", - name: "test2.h5", - }) expect(creationHandler.selectedFiles.size).toBe(1) - expect(creationHandler.modalSelectedFiles.size).toBe(1) - // Clear them - creationHandler.removeAllSelectedFiles() + creationHandler.removeAllFileSelections() expect(creationHandler.selectedFiles.size).toBe(0) - expect(creationHandler.modalSelectedFiles.size).toBe(0) }) }) }) diff --git a/gateway/sds_gateway/static/js/dataset/__tests__/DatasetEditingHandler.test.js b/gateway/sds_gateway/static/js/dataset/__tests__/DatasetEditingHandler.test.js index ff3db3aa9..28cd7d515 100644 --- a/gateway/sds_gateway/static/js/dataset/__tests__/DatasetEditingHandler.test.js +++ b/gateway/sds_gateway/static/js/dataset/__tests__/DatasetEditingHandler.test.js @@ -33,6 +33,7 @@ describe("DatasetEditingHandler", () => { initializeCapturesSearch: jest.fn(), initializeEventListeners: jest.fn(), updateSelectedFilesList: jest.fn(), + syncCommittedFileSelectionUI: jest.fn(), })), }) }) diff --git a/gateway/sds_gateway/static/js/search/AssetSearchHandler.js b/gateway/sds_gateway/static/js/search/AssetSearchHandler.js index 83eb4902e..d6ce9ab6c 100644 --- a/gateway/sds_gateway/static/js/search/AssetSearchHandler.js +++ b/gateway/sds_gateway/static/js/search/AssetSearchHandler.js @@ -354,24 +354,7 @@ class AssetSearchHandler { } if (this.confirmFileSelection) { this.confirmFileSelection.addEventListener("click", () => { - if (this.isEditMode) { - // In edit mode, add selected files to pending changes - if (window.datasetEditingHandler) { - for (const [ - fileId, - fileData, - ] of this.selectedFiles.entries()) { - window.datasetEditingHandler.addFileToPending( - fileId, - fileData, - ) - } - } - } else { - // In create mode, update the selected files list - this.updateSelectedFilesList() - } - this.handleClear() + this.mergeModalSelectionsIntoFormHandler() }) } this.initializeEnterKeyListener() @@ -494,15 +477,40 @@ class AssetSearchHandler { } } + /** + * @param {Map} map + * @param {string} fileId + * @returns {*} + */ + getMapEntry(map, fileId) { + if (!map) { + return undefined + } + return map.get(fileId) ?? map.get(String(fileId)) + } + /** * @param {string} fileId * @returns {boolean} */ isFileExistingOnDataset(fileId) { - return ( - this.isEditMode && - Boolean(this.formHandler?.currentFiles?.has(fileId)) - ) + const id = String(fileId) + if (this.isEditMode && this.formHandler) { + if (this.getMapEntry(this.formHandler.currentFiles, fileId)) { + return true + } + const pending = this.getMapEntry( + this.formHandler.pendingFiles, + fileId, + ) + return pending?.action === "add" + } + if (!this.isEditMode && this.formHandler?.selectedFiles) { + return Array.from(this.formHandler.selectedFiles).some( + (file) => String(file.id) === id, + ) + } + return false } /** @@ -605,7 +613,6 @@ class AssetSearchHandler { this.syncFolderSelectionVisual(folderLi, content, dirPath) this.updateSelectAllCheckboxState() - this.updateSelectedFilesList() } /** @@ -652,7 +659,146 @@ class AssetSearchHandler { } this.updateSelectAllCheckboxState() - this.updateSelectedFilesList() + } + + /** + * Apply modal file picks to the form handler (create Set or edit pendingFiles). + */ + mergeModalSelectionsIntoFormHandler() { + if (!this.formHandler) { + return + } + + for (const [id, file] of this.selectedFiles.entries()) { + if (this.isFileExistingOnDataset(id)) { + continue + } + + const fileObj = { ...file, id: String(id) } + + if (this.isEditMode) { + this.formHandler.addFileToPending?.(fileObj.id, fileObj, { + refreshUi: false, + }) + } else { + const alreadyOnForm = Array.from( + this.formHandler.selectedFiles, + ).some((existing) => String(existing.id) === String(id)) + if (!alreadyOnForm) { + this.formHandler.selectedFiles.add(fileObj) + } + } + } + + this.selectedFiles.clear() + + for (const checkbox of document.querySelectorAll( + AssetSearchHandler.FILE_TREE_FILE_CHECKBOX_SELECTOR, + )) { + if (checkbox.disabled) { + continue + } + checkbox.checked = false + const fileLi = checkbox.closest(".file-item") + fileLi?.classList.remove("is-selected") + fileLi?.setAttribute("aria-selected", "false") + } + + this.updateSelectAllCheckboxState() + + this.syncCommittedFileSelectionUI() + + if (this.isEditMode) { + void this.formHandler.updatePendingFilesList?.() + } else { + this.formHandler.updateHiddenFields?.() + void this.formHandler.updateSelectedFilesTable?.() + } + } + + /** + * Pending file additions (edit mode). + * @returns {[string, Object][]} + */ + getPendingFileAddEntries() { + if (!this.isEditMode || !this.formHandler?.pendingFiles) { + return [] + } + return Array.from(this.formHandler.pendingFiles.entries()).filter( + ([, change]) => change?.action === "add", + ) + } + + /** + * Confirmed file selections for create mode card table. + * @returns {[string, Object][]} + */ + getCommittedCreateModeFileEntries() { + if (!this.formHandler?.selectedFiles) { + return [] + } + return Array.from(this.formHandler.selectedFiles).map((file) => [ + String(file.id), + file, + ]) + } + + /** + * @param {number} count + * @returns {string} + */ + formatFileSelectionSummaryLabel(count) { + if (this.isEditMode) { + return `${count} pending file selection(s)` + } + return `${count} file(s) selected` + } + + /** + * Refresh browse-field summary and create-mode card table from committed state. + */ + syncCommittedFileSelectionUI() { + const summaryCount = this.isEditMode + ? this.getPendingFileAddEntries().length + : this.getCommittedCreateModeFileEntries().length + + const selectedFilesDisplay = document.getElementById( + "selected-files-display", + ) + if (selectedFilesDisplay) { + selectedFilesDisplay.value = + this.formatFileSelectionSummaryLabel(summaryCount) + } + + const removeAllButton = document.getElementById( + "remove-all-selected-files-button", + ) + if (removeAllButton && !this.isEditMode) { + removeAllButton.disabled = summaryCount === 0 + } + + if (!this.isEditMode) { + const selectedFilesTable = document.getElementById( + "selected-files-table", + ) + const selectedFilesBody = selectedFilesTable?.querySelector("tbody") + if (selectedFilesBody) { + this.renderSelectedFilesTable( + selectedFilesBody, + this.getCommittedCreateModeFileEntries(), + ) + } + } + + const countBadge = document.querySelector(".selected-files-count") + if (countBadge) { + countBadge.textContent = `${summaryCount} selected` + } + } + + /** @deprecated Use syncCommittedFileSelectionUI */ + updateSelectedFilesList() { + this.syncCommittedFileSelectionUI() } /** @@ -741,22 +887,12 @@ class AssetSearchHandler { } removeAllButton.addEventListener("click", () => { - // Check if formHandler has a custom removal handler for edit mode - if (this.formHandler?.handleRemoveAllFiles) { - this.formHandler.handleRemoveAllFiles() + if ( + typeof this.formHandler?.removeAllFileSelections === "function" + ) { + this.formHandler.removeAllFileSelections() } else { - // Default behavior for create mode - // Deselect all files - const fileCheckboxes = document.querySelectorAll( - AssetSearchHandler.FILE_TREE_FILE_CHECKBOX_SELECTOR, - ) - for (const checkbox of fileCheckboxes) { - checkbox.checked = false - checkbox.dispatchEvent(new Event("change")) - } - - this.selectedFiles.clear() - this.updateSelectedFilesList() + this.clearModalFileSelections() } }) } @@ -1374,53 +1510,6 @@ class AssetSearchHandler { this.handleSearch() } - /** - * Update selected files list - */ - updateSelectedFilesList() { - // Update form handler's selectedFiles with current selection (create mode only) - if (this.formHandler && !this.isEditMode) { - // Convert Map entries to array of file objects with IDs - const fileList = Array.from(this.selectedFiles.entries()).map( - ([id, file]) => ({ ...file, id: id }), - ) - this.formHandler.selectedFiles = new Set(fileList) - } - - // Update selected files display input - const selectedFilesDisplay = document.getElementById( - "selected-files-display", - ) - if (selectedFilesDisplay) { - selectedFilesDisplay.value = `${this.selectedFiles.size} file(s) selected` - } - - // Update Remove All button state - const removeAllButton = document.getElementById( - "remove-all-selected-files-button", - ) - if (removeAllButton) { - removeAllButton.disabled = this.selectedFiles.size === 0 - } - - // Update selected files table if it exists (only in create mode) - if (!this.isEditMode) { - const selectedFilesTable = document.getElementById( - "selected-files-table", - ) - const selectedFilesBody = selectedFilesTable?.querySelector("tbody") - if (selectedFilesBody) { - this.renderSelectedFilesTable(selectedFilesBody) - } - } - - // Update count badge - const countBadge = document.querySelector(".selected-files-count") - if (countBadge) { - countBadge.textContent = `${this.selectedFiles.size} selected` - } - } - /** * Load file tree */ @@ -1660,10 +1749,9 @@ class AssetSearchHandler { if (tree.files && tree.files.length > 0) { for (const file of tree.files) { const filePath = this.getRelativePath(file, currentPath) - const isSelected = this.selectedFiles.has(file.id) - const isExistingFile = - this.isEditMode && - this.formHandler?.currentFiles?.has(file.id) + const isExistingFile = this.isFileExistingOnDataset(file.id) + const isSelected = + !isExistingFile && this.selectedFiles.has(file.id) const li = document.createElement("li") li.className = "file-item" @@ -1731,7 +1819,6 @@ class AssetSearchHandler { } syncRowSelectionVisual() this.updateSelectAllCheckboxState() - this.updateSelectedFilesList() }) const toggleRowSelection = () => { @@ -1823,51 +1910,47 @@ class AssetSearchHandler { * Render selected files table * @param {Element} tbody - Table body element */ - async renderSelectedFilesTable(tbody) { - // Transform files data for table_rows.html template - const rows = Array.from(this.selectedFiles.entries()).map( - ([id, file]) => { - const isExistingFile = file.owner_id !== undefined - const canRemove = - !isExistingFile || - (isExistingFile && - this.formHandler.permissions?.canRemoveAsset(file)) - - return { - id: id, - css_class: !canRemove ? "readonly-row" : "", - data_attrs: { - "file-id": id, + async renderSelectedFilesTable(tbody, fileEntries = null) { + const entries = fileEntries ?? Array.from(this.selectedFiles.entries()) + const rows = entries.map(([id, file]) => { + const isExistingFile = file.owner_id !== undefined + const canRemove = + !isExistingFile || + (isExistingFile && + this.formHandler.permissions?.canRemoveAsset(file)) + + return { + id: id, + css_class: !canRemove ? "readonly-row" : "", + data_attrs: { + "file-id": id, + }, + cells: [ + { kind: "text", value: file.name }, + { kind: "text", value: file.media_type }, + { kind: "text", value: file.relative_path }, + { + kind: "text", + value: window.DOMUtils.formatFileSize(file.size), }, - cells: [ - { kind: "text", value: file.name }, - { kind: "text", value: file.media_type }, - { kind: "text", value: file.relative_path }, - { - kind: "text", - value: window.DOMUtils.formatFileSize(file.size), - }, - { - kind: "text", - value: - file.owner?.name || - file.owner?.email || - "Unknown", - }, - ], - actions: canRemove - ? [ - { - label: "Remove", - css_class: "btn-danger", - extra_class: "remove-selected-file", - data_attrs: { id: id }, - }, - ] - : [], - } - }, - ) + { + kind: "text", + value: + file.owner?.name || file.owner?.email || "Unknown", + }, + ], + actions: canRemove + ? [ + { + label: "Remove", + css_class: "btn-danger", + extra_class: "remove-selected-file", + data_attrs: { id: id }, + }, + ] + : [], + } + }) // Render using DOMUtils const success = await window.DOMUtils.renderTable(tbody, rows, { @@ -1904,22 +1987,21 @@ class AssetSearchHandler { // Check if formHandler has a custom removal handler for edit mode if (this.formHandler?.handleFileRemoval) { this.formHandler.handleFileRemoval(fileId) + } else if ( + !this.isEditMode && + typeof this.formHandler?.removeFile === "function" + ) { + this.formHandler.removeFile(fileId) } else { - // Default behavior for create mode this.selectedFiles.delete(fileId) - // Update checkbox in file tree if visible const checkbox = document.querySelector( `input[name="files"][value="${fileId}"]`, ) if (checkbox) { checkbox.checked = false } - // Update the selected files list - this.updateSelectedFilesList() - // Update form handler's hidden fields - if (this.formHandler) { - this.formHandler.updateHiddenFields() - } + this.syncCommittedFileSelectionUI() + this.formHandler?.updateHiddenFields?.() } }) } From a80ef51148789430626ba0ae3fd3e9c3f6c4b8f8 Mon Sep 17 00:00:00 2001 From: klpoland Date: Thu, 25 Jun 2026 11:41:56 -0400 Subject: [PATCH 10/11] reuseable file selection/deletion handlers, file tree bug --- .../js/dataset/DatasetCreationHandler.js | 2 +- .../js/dataset/DatasetEditingHandler.js | 2 +- .../static/js/search/AssetSearchHandler.js | 77 ++++++++++++++++--- 3 files changed, 70 insertions(+), 11 deletions(-) diff --git a/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js b/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js index 9d90ed659..130834c78 100644 --- a/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js +++ b/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js @@ -1004,7 +1004,7 @@ class DatasetCreationHandler extends BaseManager { this.selectedFiles.delete(fileToRemove) } - this.filesSearchHandler?.selectedFiles?.delete(fileId) + this.filesSearchHandler?.deleteModalSelectedFile?.(fileId) this.updateSelectedFilesDisplay() this.updateHiddenFields() diff --git a/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js b/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js index 392057ea3..5dae82107 100644 --- a/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js +++ b/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js @@ -785,7 +785,7 @@ class DatasetEditingHandler extends BaseManager { // Update visual state of current files list this.updateCurrentFilesList() } else if (change.action === "add") { - this.filesSearchHandler?.selectedFiles?.delete(fileId) + this.filesSearchHandler?.deleteModalSelectedFile?.(fileId) this.filesSearchHandler?.syncFileCheckboxVisual?.(fileId, false) } diff --git a/gateway/sds_gateway/static/js/search/AssetSearchHandler.js b/gateway/sds_gateway/static/js/search/AssetSearchHandler.js index d6ce9ab6c..dc1f036bd 100644 --- a/gateway/sds_gateway/static/js/search/AssetSearchHandler.js +++ b/gateway/sds_gateway/static/js/search/AssetSearchHandler.js @@ -38,6 +38,63 @@ class AssetSearchHandler { ), ).filter((checkbox) => checkbox.offsetParent !== null) } + + /** + * @param {string|number} fileId + * @returns {string} + */ + static normalizeFileId(fileId) { + return String(fileId) + } + + /** + * @param {string|number} fileId + * @returns {string|null} Map key if present + */ + findModalSelectedFileKey(fileId) { + const id = AssetSearchHandler.normalizeFileId(fileId) + if (this.selectedFiles.has(id)) { + return id + } + for (const key of this.selectedFiles.keys()) { + if (String(key) === id) { + return key + } + } + return null + } + + /** + * @param {string|number} fileId + * @returns {boolean} + */ + hasModalSelectedFile(fileId) { + return this.findModalSelectedFileKey(fileId) != null + } + + /** + * @param {string|number} fileId + * @returns {boolean} + */ + deleteModalSelectedFile(fileId) { + const key = this.findModalSelectedFileKey(fileId) + if (key == null) { + return false + } + this.selectedFiles.delete(key) + return true + } + + /** + * @param {Object} file + * @param {Object} data + */ + setModalSelectedFile(file, data) { + this.selectedFiles.set(AssetSearchHandler.normalizeFileId(file.id), { + ...data, + }) + } + /** * @param {object} target * @param {object} config @@ -595,15 +652,15 @@ class AssetSearchHandler { } const allSelected = entries.every(({ file }) => - this.selectedFiles.has(file.id), + this.hasModalSelectedFile(file.id), ) for (const { file, relative_path } of entries) { if (allSelected) { - this.selectedFiles.delete(file.id) + this.deleteModalSelectedFile(file.id) this.syncFileCheckboxVisual(file.id, false) } else { - this.selectedFiles.set(file.id, { + this.setModalSelectedFile(file, { ...file, relative_path, }) @@ -627,7 +684,7 @@ class AssetSearchHandler { return } const allSelected = entries.every(({ file }) => - this.selectedFiles.has(file.id), + this.hasModalSelectedFile(file.id), ) folderLi.classList.toggle("is-selected", allSelected) } @@ -1575,7 +1632,9 @@ class AssetSearchHandler { currentPath = "", searchTermEntered = false, ) { - this.currentTree = tree + if (!parentElement) { + this.currentTree = tree + } const targetElement = parentElement || this.getFileTreeRoot() if (!targetElement) { console.error("File tree root not found") @@ -1751,7 +1810,7 @@ class AssetSearchHandler { const filePath = this.getRelativePath(file, currentPath) const isExistingFile = this.isFileExistingOnDataset(file.id) const isSelected = - !isExistingFile && this.selectedFiles.has(file.id) + !isExistingFile && this.hasModalSelectedFile(file.id) const li = document.createElement("li") li.className = "file-item" @@ -1810,12 +1869,12 @@ class AssetSearchHandler { checkbox.addEventListener("change", (e) => { e.stopPropagation() if (checkbox.checked) { - this.selectedFiles.set(file.id, { + this.setModalSelectedFile(file, { ...file, relative_path: filePath, }) } else { - this.selectedFiles.delete(file.id) + this.deleteModalSelectedFile(file.id) } syncRowSelectionVisual() this.updateSelectAllCheckboxState() @@ -1993,7 +2052,7 @@ class AssetSearchHandler { ) { this.formHandler.removeFile(fileId) } else { - this.selectedFiles.delete(fileId) + this.deleteModalSelectedFile(fileId) const checkbox = document.querySelector( `input[name="files"][value="${fileId}"]`, ) From 3becbf67e964189a0624e5aee7a195230c65c6dc Mon Sep 17 00:00:00 2001 From: klpoland Date: Thu, 25 Jun 2026 13:51:59 -0400 Subject: [PATCH 11/11] more bugfixes --- .../static/js/dataset/AuthorsManager.js | 4 ++ .../js/dataset/DatasetEditingHandler.js | 72 ++++++++++++------- .../static/js/search/AssetSearchHandler.js | 9 +++ 3 files changed, 59 insertions(+), 26 deletions(-) diff --git a/gateway/sds_gateway/static/js/dataset/AuthorsManager.js b/gateway/sds_gateway/static/js/dataset/AuthorsManager.js index 9615377a5..81030d407 100644 --- a/gateway/sds_gateway/static/js/dataset/AuthorsManager.js +++ b/gateway/sds_gateway/static/js/dataset/AuthorsManager.js @@ -225,6 +225,10 @@ class AuthorsManager { static bindFileTreeModalHandlers(handler) { const modal = document.getElementById("fileTreeModal") if (!modal) return + if (modal.dataset.fileTreeModalHandlersBound === "true") { + return + } + modal.dataset.fileTreeModalHandlersBound = "true" modal.addEventListener("show.bs.modal", () => { handler.onFileModalShow() }) diff --git a/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js b/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js index 5dae82107..b23608b12 100644 --- a/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js +++ b/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js @@ -86,6 +86,8 @@ class DatasetEditingHandler extends BaseManager { this.initialCaptures, this.initialFiles, ) + + this.initializeFileBrowserModal() } const datasetForm = document.getElementById("datasetForm") @@ -122,18 +124,6 @@ class DatasetEditingHandler extends BaseManager { this.filesSearchHandler = searchHandler this.filesSearchHandler?.syncCommittedFileSelectionUI?.() } - - // If we have initial data and both handlers are ready, populate the data - if ( - this.capturesSearchHandler && - this.filesSearchHandler && - (this.initialCaptures.length > 0 || this.initialFiles.length > 0) - ) { - this.populateFromInitialData( - this.initialCaptures, - this.initialFiles, - ) - } } /** @@ -208,9 +198,6 @@ class DatasetEditingHandler extends BaseManager { // Add event listeners for remove buttons this.addRemoveButtonListeners() - - // Initialize file browser modal handlers - this.initializeFileBrowserModal() } /** @@ -234,6 +221,7 @@ class DatasetEditingHandler extends BaseManager { this.filesSearchHandler.updateFilesTable({ tree: this.filesSearchHandler.currentTree, }) + this.syncAllPendingFileRemovalStylesInTree() } /** @@ -497,6 +485,47 @@ class DatasetEditingHandler extends BaseManager { } } + /** + * Sync strikethrough / checkbox on a file-tree row (modal browser). + * @param {string} fileId + * @param {boolean} markedForRemoval + */ + syncFileSearchRowRemovalStyle(fileId, markedForRemoval) { + const id = String(fileId) + const searchRow = document.querySelector( + `#file-tree-root li[data-file-id="${id}"]`, + ) + if (!searchRow) { + return + } + + if (markedForRemoval) { + searchRow.classList.add("marked-for-removal") + const checkbox = searchRow.querySelector('input[type="checkbox"]') + if (checkbox) { + checkbox.checked = true + } + return + } + + searchRow.classList.remove("marked-for-removal") + const checkbox = searchRow.querySelector('input[type="checkbox"]') + if (checkbox) { + checkbox.checked = false + } + } + + /** + * Re-apply pending removal styling after the file tree is rebuilt. + */ + syncAllPendingFileRemovalStylesInTree() { + for (const [fileId, change] of this.pendingFiles.entries()) { + if (change.action === "remove") { + this.syncFileSearchRowRemovalStyle(fileId, true) + } + } + } + /** * Mark capture for removal * @param {string} captureId - Capture ID to mark for removal @@ -590,16 +619,7 @@ class DatasetEditingHandler extends BaseManager { this.updateCurrentFilesList() - const searchRow = document.querySelector( - `#file-tree-root li[data-file-id="${id}"]`, - ) - if (searchRow) { - searchRow.classList.add("marked-for-removal") - const checkbox = searchRow.querySelector('input[type="checkbox"]') - if (checkbox) { - checkbox.checked = true - } - } + this.syncFileSearchRowRemovalStyle(id, true) void this.updatePendingFilesList() this.filesSearchHandler?.syncCommittedFileSelectionUI?.() @@ -782,8 +802,8 @@ class DatasetEditingHandler extends BaseManager { } if (change.action === "remove") { - // Update visual state of current files list this.updateCurrentFilesList() + this.syncFileSearchRowRemovalStyle(id, false) } else if (change.action === "add") { this.filesSearchHandler?.deleteModalSelectedFile?.(fileId) this.filesSearchHandler?.syncFileCheckboxVisual?.(fileId, false) diff --git a/gateway/sds_gateway/static/js/search/AssetSearchHandler.js b/gateway/sds_gateway/static/js/search/AssetSearchHandler.js index dc1f036bd..5f8b197e7 100644 --- a/gateway/sds_gateway/static/js/search/AssetSearchHandler.js +++ b/gateway/sds_gateway/static/js/search/AssetSearchHandler.js @@ -1905,6 +1905,14 @@ class AssetSearchHandler { li.setAttribute("tabindex", "-1") li.classList.add("readonly-row") li.title = "This file is already in the dataset" + const pendingRemove = + this.isEditMode && + this.formHandler?.getPendingFileChange?.(file.id) + ?.action === "remove" + if (pendingRemove) { + li.classList.add("marked-for-removal") + checkbox.checked = true + } } targetElement.appendChild(li) @@ -1912,6 +1920,7 @@ class AssetSearchHandler { } this.updateSelectAllCheckboxState() + this.formHandler?.syncAllPendingFileRemovalStylesInTree?.() } /**