diff --git a/.cursor/rules/django_javascript_implementation.mdc b/.cursor/rules/django_javascript_implementation.mdc index d4a0db04..c5e74576 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/api_methods/tests/test_dataset_endpoints.py b/gateway/sds_gateway/api_methods/tests/test_dataset_endpoints.py index 49cc6ec2..f618ff12 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) diff --git a/gateway/sds_gateway/static/css/components.css b/gateway/sds_gateway/static/css/components.css index 0a9e57e5..973aa8ca 100644 --- a/gateway/sds_gateway/static/css/components.css +++ b/gateway/sds_gateway/static/css/components.css @@ -1127,21 +1127,10 @@ body { border-radius: 0.375rem; } -#file-tree-table { +#file-tree-root { 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; -} - .action-buttons { display: flex; gap: 0.5rem; diff --git a/gateway/sds_gateway/static/css/spectrumx_theme.css b/gateway/sds_gateway/static/css/spectrumx_theme.css index 46111f26..b6ed7e40 100644 --- a/gateway/sds_gateway/static/css/spectrumx_theme.css +++ b/gateway/sds_gateway/static/css/spectrumx_theme.css @@ -267,6 +267,116 @@ textarea:focus-visible { max-width: 600px; } +.file-browser-modal { + max-width: none; + 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 [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); +} + +.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 .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; +} + +.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; @@ -450,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/core/PageLifecycleManager.js b/gateway/sds_gateway/static/js/core/PageLifecycleManager.js index eed28698..951a84a1 100644 --- a/gateway/sds_gateway/static/js/core/PageLifecycleManager.js +++ b/gateway/sds_gateway/static/js/core/PageLifecycleManager.js @@ -241,7 +241,7 @@ class PageLifecycleManager { searchFormId: "files-search-form", searchButtonId: "search-files", clearButtonId: "clear-files-search", - tableBodyId: "file-tree-table", + tableBodyId: "file-tree-root", paginationContainerId: "files-pagination", type: "files", formHandler: this.datasetModeManager?.getHandler(), diff --git a/gateway/sds_gateway/static/js/dataset/AuthorsManager.js b/gateway/sds_gateway/static/js/dataset/AuthorsManager.js index 9615377a..81030d40 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/DatasetCreationHandler.js b/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js index 8237a9de..130834c7 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 @@ -77,7 +76,7 @@ class DatasetCreationHandler extends BaseManager { searchFormId: "files-search-form", searchButtonId: "search-files", clearButtonId: "clear-files-search", - tableBodyId: "file-tree-table", + tableBodyId: "file-tree-root", paginationContainerId: "files-pagination", confirmFileSelectionId: "confirm-file-selection", type: "files", @@ -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-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() - }) } /** @@ -298,162 +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 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) { + 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-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() + this.filesSearchHandler?.syncCommittedFileSelectionUI?.() + void this.updateSelectedFilesTable() } /** @@ -1149,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?.deleteModalSelectedFile?.(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 c85392bb..b23608b1 100644 --- a/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js +++ b/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js @@ -63,7 +63,7 @@ class DatasetEditingHandler extends BaseManager { searchFormId: "files-search-form", searchButtonId: "search-files", clearButtonId: "clear-files-search", - tableBodyId: "file-tree-table", + tableBodyId: "file-tree-root", paginationContainerId: "files-pagination", confirmFileSelectionId: "confirm-file-selection", type: "files", @@ -86,6 +86,25 @@ class DatasetEditingHandler extends BaseManager { this.initialCaptures, this.initialFiles, ) + + this.initializeFileBrowserModal() + } + + 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") { + return + } + if (e.target instanceof HTMLTextAreaElement) { + return + } + e.preventDefault() + }) } } @@ -103,19 +122,7 @@ class DatasetEditingHandler extends BaseManager { }) } 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, - ) + this.filesSearchHandler?.syncCommittedFileSelectionUI?.() } } @@ -186,19 +193,11 @@ 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 this.addRemoveButtonListeners() - - // Initialize file browser modal handlers - this.initializeFileBrowserModal() } /** @@ -212,17 +211,24 @@ 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, + }) + this.syncAllPendingFileRemovalStylesInTree() } /** * Handle file modal hide */ onFileModalHide() { - // Clear any intermediate state if needed + this.filesSearchHandler?.clearModalFileSelections() } /** @@ -479,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 @@ -521,12 +568,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`) @@ -546,33 +612,21 @@ 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 + this.syncFileSearchRowRemovalStyle(id, true) + + void this.updatePendingFilesList() + this.filesSearchHandler?.syncCommittedFileSelectionUI?.() + 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() } /** @@ -615,30 +669,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() } @@ -734,24 +790,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() + this.syncFileSearchRowRemovalStyle(id, false) } else if (change.action === "add") { - // Remove from SearchHandler's selectedFiles if it exists - if (this.filesSearchHandler) { - this.filesSearchHandler.selectedFiles.delete(fileId) - } + this.filesSearchHandler?.deleteModalSelectedFile?.(fileId) + this.filesSearchHandler?.syncFileCheckboxVisual?.(fileId, false) } this.updatePendingFilesList() + this.filesSearchHandler?.syncCommittedFileSelectionUI?.() - // Update review display if (window.updateReviewDatasetDisplay) { window.updateReviewDatasetDisplay() } @@ -795,32 +855,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") } } } @@ -841,7 +893,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 70fb376c..caf500d9 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 ff3db3aa..28cd7d51 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 8f47e4ea..5f8b197e 100644 --- a/gateway/sds_gateway/static/js/search/AssetSearchHandler.js +++ b/gateway/sds_gateway/static/js/search/AssetSearchHandler.js @@ -17,6 +17,84 @@ 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 {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 @@ -289,7 +367,13 @@ class AssetSearchHandler { config.formHandler.setSearchHandler(this, config.type) } + this._coreSearchListenersBound = false + this.folderSelectionMode = false + this.initializeEventListeners() + if (this.type === "files") { + this.initializeFolderSelectionControls() + } } /** @@ -311,6 +395,11 @@ class AssetSearchHandler { * Initialize event listeners */ initializeEventListeners() { + if (this._coreSearchListenersBound) { + return + } + this._coreSearchListenersBound = true + // Search form handlers if (this.searchButton) { this.searchButton.addEventListener("click", () => @@ -322,24 +411,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() @@ -358,6 +430,8 @@ class AssetSearchHandler { for (const input of searchInputs) { input.addEventListener("keypress", (e) => { if (e.key === "Enter") { + e.preventDefault() + e.stopPropagation() this.handleSearch() } }) @@ -372,12 +446,14 @@ 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 - const fileCheckboxes = document.querySelectorAll( - '#file-tree-table tbody input[type="checkbox"]', - ) + const fileCheckboxes = this.getVisibleFileCheckboxes() for (const checkbox of fileCheckboxes) { if (checkbox.checked !== isChecked) { @@ -388,6 +464,472 @@ 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 {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) { + 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 + } + + /** + * @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 + * @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 + */ + 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") + fileLi?.classList.toggle("is-selected", checked) + fileLi?.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.hasModalSelectedFile(file.id), + ) + + for (const { file, relative_path } of entries) { + if (allSelected) { + this.deleteModalSelectedFile(file.id) + this.syncFileCheckboxVisual(file.id, false) + } else { + this.setModalSelectedFile(file, { + ...file, + relative_path, + }) + this.syncFileCheckboxVisual(file.id, true) + } + } + + this.syncFolderSelectionVisual(folderLi, content, dirPath) + this.updateSelectAllCheckboxState() + } + + /** + * @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.hasModalSelectedFile(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") + fileLi?.setAttribute("aria-selected", "false") + } + + for (const folderLi of document.querySelectorAll( + "#file-tree-root .folder-item", + )) { + folderLi.classList.remove("is-selected") + } + + this.updateSelectAllCheckboxState() + } + + /** + * 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() + } + + /** + * @param {HTMLElement} folderLi + * @param {HTMLElement} childUl + * @param {Object} content + * @param {string} dirPath + * @param {number} level + * @param {boolean} searchTermEntered + * @param {boolean} expanded + */ + setFolderExpanded( + folderLi, + childUl, + content, + dirPath, + level, + searchTermEntered, + expanded, + ) { + folderLi.setAttribute("aria-expanded", expanded ? "true" : "false") + + 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( + 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} folderLi + * @param {HTMLElement} childUl + * @param {Object} content + * @param {string} dirPath + * @param {number} level + * @param {boolean} searchTermEntered + */ + toggleFolderExpanded( + folderLi, + childUl, + content, + dirPath, + level, + searchTermEntered, + ) { + const isExpanded = folderLi.getAttribute("aria-expanded") === "true" + this.setFolderExpanded( + folderLi, + childUl, + content, + dirPath, + level, + searchTermEntered, + !isExpanded, + ) + } + /** * Initialize remove all button */ @@ -396,24 +938,18 @@ 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 - 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( - '#file-tree-table tbody input[type="checkbox"]', - ) - for (const checkbox of fileCheckboxes) { - checkbox.checked = false - checkbox.dispatchEvent(new Event("change")) - } - - this.selectedFiles.clear() - this.updateSelectedFilesList() + this.clearModalFileSelections() } }) } @@ -1031,53 +1567,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 */ @@ -1105,9 +1594,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() @@ -1146,11 +1632,12 @@ class AssetSearchHandler { currentPath = "", searchTermEntered = false, ) { - this.currentTree = tree - const targetElement = - parentElement || document.querySelector("#file-tree-table tbody") + if (!parentElement) { + this.currentTree = tree + } + const targetElement = parentElement || this.getFileTreeRoot() if (!targetElement) { - console.error("File tree table body not found") + console.error("File tree root not found") return } @@ -1158,18 +1645,16 @@ class AssetSearchHandler { 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 = - '
| - | Name | -Type | -Size | -Created At | -
|---|---|---|---|---|
| - - Use the search form above to browse files - | -||||
+ When enabled, click a folder row to select all files inside it. Use the arrow to expand subfolders. +
+ +