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..dcd38d46 100644 --- a/gateway/sds_gateway/static/css/spectrumx_theme.css +++ b/gateway/sds_gateway/static/css/spectrumx_theme.css @@ -267,6 +267,83 @@ 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 .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/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/DatasetCreationHandler.js b/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js index 8237a9de..fb219785 100644 --- a/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js +++ b/gateway/sds_gateway/static/js/dataset/DatasetCreationHandler.js @@ -77,7 +77,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", @@ -220,7 +220,7 @@ class DatasetCreationHandler extends BaseManager { initializeFileBrowserModal() { // Modal file selection handlers document.addEventListener("change", (e) => { - if (e.target.matches('#file-tree-table input[name="files"]')) { + if (e.target.matches('#file-tree-root input[name="files"]')) { this.handleModalFileSelection(e.target) } }) @@ -344,7 +344,7 @@ class DatasetCreationHandler extends BaseManager { */ handleSelectAllFiles(checked) { const checkboxes = document.querySelectorAll( - '#file-tree-table input[name="files"]', + '#file-tree-root input[name="files"]', ) for (const checkbox of checkboxes) { checkbox.checked = checked @@ -360,7 +360,7 @@ class DatasetCreationHandler extends BaseManager { "select-all-files-checkbox", ) const allCheckboxes = document.querySelectorAll( - '#file-tree-table input[name="files"]', + '#file-tree-root input[name="files"]', ) if (selectAllCheckbox && allCheckboxes.length > 0) { @@ -432,7 +432,7 @@ class DatasetCreationHandler extends BaseManager { // Uncheck all checkboxes in modal if it's open const checkboxes = document.querySelectorAll( - '#file-tree-table input[name="files"]', + '#file-tree-root input[name="files"]', ) for (const checkbox of checkboxes) { checkbox.checked = false diff --git a/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js b/gateway/sds_gateway/static/js/dataset/DatasetEditingHandler.js index c85392bb..19d18783 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", @@ -87,6 +87,19 @@ class DatasetEditingHandler extends BaseManager { 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() + } + }) + } } /** @@ -562,7 +575,7 @@ class DatasetEditingHandler extends BaseManager { // Also mark in the search results table if visible const searchRow = document.querySelector( - `#file-tree-table tr[data-file-id="${fileId}"]`, + `#file-tree-root li[data-file-id="${fileId}"]`, ) if (searchRow) { searchRow.classList.add("marked-for-removal") diff --git a/gateway/sds_gateway/static/js/search/AssetSearchHandler.js b/gateway/sds_gateway/static/js/search/AssetSearchHandler.js index 8f47e4ea..39c89dbe 100644 --- a/gateway/sds_gateway/static/js/search/AssetSearchHandler.js +++ b/gateway/sds_gateway/static/js/search/AssetSearchHandler.js @@ -17,6 +17,27 @@ 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 @@ -289,6 +310,8 @@ class AssetSearchHandler { config.formHandler.setSearchHandler(this, config.type) } + this._coreSearchListenersBound = false + this.initializeEventListeners() } @@ -311,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", () => @@ -358,6 +386,8 @@ class AssetSearchHandler { for (const input of searchInputs) { input.addEventListener("keypress", (e) => { if (e.key === "Enter") { + e.preventDefault() + e.stopPropagation() this.handleSearch() } }) @@ -372,12 +402,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) { @@ -396,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 @@ -405,7 +441,7 @@ class AssetSearchHandler { // Default behavior for create mode // Deselect all files const fileCheckboxes = document.querySelectorAll( - '#file-tree-table tbody input[type="checkbox"]', + AssetSearchHandler.FILE_TREE_FILE_CHECKBOX_SELECTOR, ) for (const checkbox of fileCheckboxes) { checkbox.checked = false @@ -1105,9 +1141,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() @@ -1147,10 +1180,9 @@ class AssetSearchHandler { searchTermEntered = false, ) { this.currentTree = tree - const targetElement = - parentElement || document.querySelector("#file-tree-table tbody") + 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 +1190,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 = - 'No files or directories found' + '
  • No files or directories found
  • ' return } - // Show/hide select all checkbox based on search term const selectAllContainer = document.getElementById( "select-all-container", ) @@ -1182,7 +1212,6 @@ class AssetSearchHandler { } } - // Render directories const directories = tree.children || {} for (const [name, content] of Object.entries(directories)) { @@ -1196,182 +1225,186 @@ class AssetSearchHandler { 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 folderIcon = initiallyExpanded + ? "bi-folder2-open" + : "bi-folder-fill" const dirPath = currentPath ? `${currentPath}/${content.name || name}` : content.name || name - - row.innerHTML = ` - - ${toggleSymbol} - - - + 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} - - 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") + 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" } - nestedContainer.innerHTML = ` - -
    - - -
    -
    - - ` - targetElement.appendChild(nestedContainer) - // Add click handler for folder - row.addEventListener("click", (e) => { + rowSpan.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) { + return + } - if (expandable) { - toggle.textContent = isExpanded ? "▶" : "▼" + const isExpanded = + rowSpan.getAttribute("aria-expanded") === "true" + const newExpanded = !isExpanded + rowSpan.setAttribute( + "aria-expanded", + newExpanded ? "true" : "false", + ) - 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") + 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", + ) } - // Load nested content if not already loaded - if ( - expandable && - !isExpanded && - !nestedContainer.dataset.loaded - ) { + if (newExpanded && childUl.dataset.loaded !== "true") { this.renderFileTree( content, - nestedContainer.querySelector("tbody"), + childUl, level + 1, dirPath, searchTermEntered, ) - nestedContainer.dataset.loaded = "true" + childUl.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 = ` - - + - - - - ${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" })} + ${isExistingFile ? "disabled" : ""} + aria-hidden="true" + tabindex="-1"> + + ${file.name} + ` - const checkbox = row.querySelector('input[type="checkbox"]') + li.appendChild(rowSpan) + const checkbox = rowSpan.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 + 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() // Prevent row click from firing + e.stopPropagation() 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) } + syncRowSelectionVisual() 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 - + const toggleRowSelection = () => { checkbox.checked = !checkbox.checked - // Trigger the change event to ensure the selectedFiles is updated checkbox.dispatchEvent(new Event("change")) + } + + rowSpan.addEventListener("click", (e) => { + if (e.target.type === "checkbox") { + return + } + toggleRowSelection() }) - // Add hover effect class - row.classList.add("clickable-row") + rowSpan.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + toggleRowSelection() + } + }) + + li.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" + li.classList.add("readonly-row") + li.title = "This file is already in the dataset" } - targetElement.appendChild(row) + targetElement.appendChild(li) } } - // Update select all checkbox state when rendering new tree this.updateSelectAllCheckboxState() } @@ -1380,15 +1413,15 @@ class AssetSearchHandler { * @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") + const root = this.getFileTreeRoot() + if (!root) { + console.error("File tree root not found") return } - tbody.innerHTML = "" + root.innerHTML = "" if (!data.tree) { - this.renderEmptyFilesTable(tbody) + this.renderEmptyFileTree(root) return } @@ -1396,32 +1429,12 @@ class AssetSearchHandler { } /** - * Render empty files table asynchronously + * Render empty file tree placeholder + * @param {HTMLElement} root - File tree root element */ - 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' - } + renderEmptyFileTree(root) { + root.innerHTML = + '
  • No files or directories found
  • ' } /** @@ -1561,15 +1574,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) { + if ( + checkedBoxes.length === fileCheckboxes.length && + fileCheckboxes.length > 0 + ) { selectAllCheckbox.checked = true } else { selectAllCheckbox.checked = false 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 4c21af51..6ab0b46b 100644 --- a/gateway/sds_gateway/static/js/search/__tests__/AssetSearchHandler.test.js +++ b/gateway/sds_gateway/static/js/search/__tests__/AssetSearchHandler.test.js @@ -279,8 +279,8 @@ describe("AssetSearchHandler", () => { createModeHandler = new AssetSearchHandler(createConfig) // Mock target element for rendering - mockTargetElement = document.createElement("tbody") - mockTargetElement.id = "file-tree-table-body" + mockTargetElement = document.createElement("ul") + mockTargetElement.id = "file-tree-root" // Mock DOMUtils.formatFileSize global.window.DOMUtils.formatFileSize = jest.fn( @@ -303,7 +303,7 @@ describe("AssetSearchHandler", () => { editModeHandler.renderFileTree(tree, mockTargetElement) - const row = mockTargetElement.querySelector("tr") + const row = mockTargetElement.querySelector("li.file-item") const checkbox = row.querySelector('input[type="checkbox"]') // Check that checkbox is disabled @@ -337,7 +337,7 @@ describe("AssetSearchHandler", () => { editModeHandler.renderFileTree(tree, mockTargetElement) - const row = mockTargetElement.querySelector("tr") + const row = mockTargetElement.querySelector("li.file-item") const checkbox = row.querySelector('input[type="checkbox"]') // Check that checkbox is NOT disabled @@ -374,7 +374,7 @@ describe("AssetSearchHandler", () => { createModeHandler.renderFileTree(tree, mockTargetElement) - const row = mockTargetElement.querySelector("tr") + const row = mockTargetElement.querySelector("li.file-item") const checkbox = row.querySelector('input[type="checkbox"]') // In create mode, all checkboxes should be enabled diff --git a/gateway/sds_gateway/templates/users/partials/file_browser.html b/gateway/sds_gateway/templates/users/partials/file_browser.html index 556484c3..545c3baf 100644 --- a/gateway/sds_gateway/templates/users/partials/file_browser.html +++ b/gateway/sds_gateway/templates/users/partials/file_browser.html @@ -102,27 +102,19 @@ 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 +
    • +
    +