diff --git a/gateway/sds_gateway/static/js/__tests__/FileListPageController.test.js b/gateway/sds_gateway/static/js/__tests__/FileListPageController.test.js new file mode 100644 index 000000000..7b216d3bf --- /dev/null +++ b/gateway/sds_gateway/static/js/__tests__/FileListPageController.test.js @@ -0,0 +1,298 @@ +/** + * Jest tests for captures file list page (FileListPageController). + */ + +class MockSearchManager { + constructor(options) { + this.options = options; + } +} + +class MockModalManager { + constructor(options) { + this.options = options; + } +} + +const MockModalManagerConstructor = jest.fn(function MockMM(options) { + return new MockModalManager(options); +}); +MockModalManagerConstructor.attachDocumentCaptureClickDelegation = jest.fn( + () => jest.fn(), +); + +global.ModalManager = MockModalManagerConstructor; +global.window.ModalManager = MockModalManagerConstructor; +global.window.SearchManager = MockSearchManager; + +global.window.FileListConfig = { + DEBOUNCE_DELAY: 300, + DEFAULT_SORT_BY: "created_at", + DEFAULT_SORT_ORDER: "desc", + MIN_LOADING_TIME: 500, + ELEMENT_IDS: { + SEARCH_INPUT: "search-input", + START_DATE: "start_date", + END_DATE: "end_date", + CENTER_FREQ_MIN: "centerFreqMinInput", + CENTER_FREQ_MAX: "centerFreqMaxInput", + APPLY_FILTERS: "apply-filters-btn", + CLEAR_FILTERS: "clear-filters-btn", + ITEMS_PER_PAGE: "items-per-page", + }, +}; + +const { PageController } = require("../core/PageController.js"); +const { PageLifecycleManager } = require("../core/PageLifecycleManager.js"); +global.window.PageController = PageController; +global.window.PageLifecycleManager = PageLifecycleManager; + +global.window.DOMUtils = { + escapeHtml: jest.fn((str) => { + if (!str) return ""; + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + }), + formatDateForModal: jest.fn((date) => { + if (!date) return "-"; + const d = new Date(date); + return d.toISOString().split("T")[0]; + }), + initIconDropdowns: jest.fn(), + renderLoading: jest.fn().mockResolvedValue(true), + renderError: jest.fn().mockResolvedValue(true), +}; + +global.bootstrap.Dropdown = jest + .fn() + .mockImplementation((element, options) => ({ + show: jest.fn(), + hide: jest.fn(), + element: element, + options: options, + })); + +const { FileListPageController } = require("../captures/FileListPageController.js"); + +describe("FileListPageController", () => { + let fileListController; + let mockElements; + let mockSearchManager; + let mockModalManager; + let loadTableSpy; + + beforeEach(() => { + jest.clearAllMocks(); + + delete global.window.__FILE_LIST_PAGE_LIFECYCLE__; + delete global.window.__FILE_LIST_LIST_REFRESH__; + delete global.window.pageLifecycleManager; + loadTableSpy = jest.fn().mockResolvedValue("
"); + global.window.listRefreshManager = { loadTable: loadTableSpy }; + + mockElements = { + searchInput: { + value: "", + addEventListener: jest.fn(), + }, + startDate: { value: "", addEventListener: jest.fn() }, + endDate: { value: "", addEventListener: jest.fn() }, + centerFreqMin: { value: "", addEventListener: jest.fn() }, + centerFreqMax: { value: "", addEventListener: jest.fn() }, + applyFilters: { addEventListener: jest.fn() }, + clearFilters: { addEventListener: jest.fn() }, + itemsPerPage: { value: "25", addEventListener: jest.fn() }, + sortableHeaders: [], + frequencyButton: { addEventListener: jest.fn() }, + frequencyCollapse: {}, + dateButton: { addEventListener: jest.fn() }, + dateCollapse: {}, + }; + + document.getElementById = jest.fn((id) => { + const idMap = { + "search-input": mockElements.searchInput, + start_date: mockElements.startDate, + end_date: mockElements.endDate, + centerFreqMinInput: mockElements.centerFreqMin, + centerFreqMaxInput: mockElements.centerFreqMax, + "apply-filters-btn": mockElements.applyFilters, + "clear-filters-btn": mockElements.clearFilters, + "items-per-page": mockElements.itemsPerPage, + collapseFrequency: mockElements.frequencyCollapse, + collapseDate: mockElements.dateCollapse, + "captures-table": { classList: { contains: jest.fn(), add: jest.fn(), remove: jest.fn() }, addEventListener: jest.fn(), querySelector: jest.fn() }, + "add-captures-to-dataset-btn": null, + }; + return idMap[id] || null; + }); + + document.querySelector = jest.fn((selector) => { + if (selector === '[data-bs-target="#collapseFrequency"]') { + return mockElements.frequencyButton; + } + if (selector === '[data-bs-target="#collapseDate"]') { + return mockElements.dateButton; + } + if (selector === "th.sortable") { + return []; + } + return null; + }); + + document.querySelectorAll = jest.fn(() => []); + + window.location = { + pathname: "/users/capture-list/", + search: "", + }; + window.history = { + pushState: jest.fn(), + }; + + window.URLSearchParams = class URLSearchParams { + constructor(search) { + this.params = new Map(); + const q = typeof search === "string" ? search.replace("?", "") : ""; + if (q) { + for (const pair of q.split("&")) { + const [key, value] = pair.split("="); + if (key) this.params.set(key, decodeURIComponent(value || "")); + } + } + } + get(name) { + return this.params.has(name) ? this.params.get(name) : null; + } + set(name, value) { + this.params.set(name, value); + } + delete(name) { + this.params.delete(name); + } + *entries() { + yield* this.params.entries(); + } + toString() { + return Array.from(this.params.entries()) + .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) + .join("&"); + } + }; + + mockSearchManager = new MockSearchManager({ + searchInputId: "search-input", + searchButtonId: "search-btn", + clearButtonId: "reset-search-btn", + }); + + mockModalManager = new MockModalManager({ + modalId: "capture-modal", + modalBodyId: "capture-modal-body", + }); + + MockModalManagerConstructor.mockImplementation(() => mockModalManager); + global.SearchManager = jest.fn(() => mockSearchManager); + global.window.SearchManager = global.SearchManager; + }); + + describe("Initialization", () => { + test("should initialize with default sort values", () => { + window.location.search = ""; + fileListController = new FileListPageController(); + + expect(fileListController.currentSortBy).toBe("created_at"); + expect(fileListController.currentSortOrder).toBe("desc"); + }); + + test("should initialize with URL params", () => { + Object.defineProperty(window, "location", { + value: { + search: "?sort_by=name&sort_order=asc", + pathname: "/captures/", + }, + writable: true, + }); + + fileListController = new FileListPageController(); + + expect(fileListController.currentSortBy).toBe("name"); + expect(fileListController.currentSortOrder).toBe("asc"); + }); + + test("should cache DOM elements", () => { + fileListController = new FileListPageController(); + + expect(fileListController.elements).toBeDefined(); + expect(fileListController.elements.searchInput).toBe( + mockElements.searchInput, + ); + expect(fileListController.elements.startDate).toBe( + mockElements.startDate, + ); + }); + + test("should initialize component managers", () => { + fileListController = new FileListPageController(); + + expect(global.ModalManager).toHaveBeenCalled(); + expect(global.SearchManager).toHaveBeenCalled(); + expect(ModalManager.attachDocumentCaptureClickDelegation).toHaveBeenCalled(); + expect(fileListController.modalManager).toBe(mockModalManager); + expect(fileListController.searchManager).toBe(mockSearchManager); + expect(fileListController.listRefreshManager).toBe( + global.window.listRefreshManager, + ); + }); + }); + + describe("Search functionality", () => { + beforeEach(() => { + fileListController = new FileListPageController(); + }); + + test("buildSearchParams should include all filter values", () => { + mockElements.searchInput.value = "test search"; + mockElements.startDate.value = "2024-01-01"; + mockElements.endDate.value = "2024-12-31"; + mockElements.centerFreqMin.value = "1.0"; + mockElements.centerFreqMax.value = "5.0"; + + fileListController.userInteractedWithFrequency = true; + + const params = fileListController.buildSearchParams(); + + expect(params.get("search")).toBe("test search"); + expect(params.get("date_start")).toBe("2024-01-01"); + expect(params.get("date_end")).toBe("2024-12-31T23:59:59"); + expect(params.get("min_freq")).toBe("1.0"); + expect(params.get("max_freq")).toBe("5.0"); + expect(params.get("sort_by")).toBe("created_at"); + expect(params.get("sort_order")).toBe("desc"); + }); + + test("buildSearchParams should handle empty values", () => { + mockElements.searchInput.value = ""; + mockElements.startDate.value = ""; + + const params = fileListController.buildSearchParams(); + + expect(params.get("search")).toBeNull(); + expect(params.get("date_start")).toBeNull(); + expect(params.get("sort_by")).toBe("created_at"); + expect(params.get("sort_order")).toBe("desc"); + }); + + test("performSearch should load table via ListRefreshManager", async () => { + jest.spyOn(window.history, "pushState").mockImplementation(() => {}); + await fileListController.performSearch(); + + expect(loadTableSpy).toHaveBeenCalled(); + expect(window.history.pushState).toHaveBeenCalled(); + }); + }); +}); diff --git a/gateway/sds_gateway/static/js/__tests__/PaginationManager.behavior.test.js b/gateway/sds_gateway/static/js/__tests__/PaginationManager.behavior.test.js new file mode 100644 index 000000000..22be96dd5 --- /dev/null +++ b/gateway/sds_gateway/static/js/__tests__/PaginationManager.behavior.test.js @@ -0,0 +1,76 @@ +/** + * Pagination: deprecated client-rendered PaginationManager vs + * PageLifecycleManager.wireServerRenderedPagination (server HTML links). + */ +const paginationPayload = { + num_pages: 5, + number: 2, + has_previous: true, + has_next: true, +}; + +function mountPaginationPage(url) { + document.body.innerHTML = ""; + window.history.replaceState({}, "", url); +} + +describe("deprecated components.js PaginationManager — click invokes onPageChange", () => { + let DeprecatedPaginationManager; + + beforeAll(() => { + // eslint-disable-next-line global-require + ({ PaginationManager: DeprecatedPaginationManager } = require("../deprecated/components.js")); + }); + + beforeEach(() => { + mountPaginationPage("http://localhost/captures/?page=1"); + const host = document.createElement("div"); + host.id = "pag-host"; + document.body.appendChild(host); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + test("clicking a rendered page link calls onPageChange with that page number", () => { + const onPageChange = jest.fn(); + const mgr = new DeprecatedPaginationManager({ + containerId: "pag-host", + onPageChange, + }); + mgr.update(paginationPayload); + const linkTo4 = Array.from( + document.querySelectorAll("a.page-link"), + ).find((a) => a.getAttribute("data-page") === "4"); + expect(linkTo4).toBeTruthy(); + linkTo4.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + expect(onPageChange).toHaveBeenCalledWith(4); + }); +}); + +describe("PageLifecycleManager.wireServerRenderedPagination", () => { + const { PageLifecycleManager } = require("../core/PageLifecycleManager.js"); + + beforeEach(() => { + mountPaginationPage("http://localhost/captures/?page=1"); + const host = document.createElement("div"); + host.id = "pag-host-core"; + host.innerHTML = + ''; + document.body.appendChild(host); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + test("clicking a server-rendered page link calls onPageChange with that page number", () => { + const onPageChange = jest.fn(); + PageLifecycleManager.wireServerRenderedPagination("pag-host-core", onPageChange); + const linkTo4 = document.querySelector("#pag-host-core a.page-link"); + expect(linkTo4).toBeTruthy(); + linkTo4.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + expect(onPageChange).toHaveBeenCalledWith(4); + }); +}); diff --git a/gateway/sds_gateway/static/js/__tests__/TableManager.sortBehavior.test.js b/gateway/sds_gateway/static/js/__tests__/TableManager.sortBehavior.test.js new file mode 100644 index 000000000..cb0d25039 --- /dev/null +++ b/gateway/sds_gateway/static/js/__tests__/TableManager.sortBehavior.test.js @@ -0,0 +1,78 @@ +/** + * Behavioral contract: table header sort updates the URL the same way + * deprecated/components.js TableManager did (pushState). + * + * Uses the default Jest jsdom document/window. Swaps in the real URL / + * URLSearchParams implementations because jest.setup.js provides a minimal + * URLSearchParams that mishandles `window.location.search` (leading `?`). + */ +const TABLE_BODY = ` + + + +
+
+`; + +function mountSortPage(searchParamsObj) { + document.body.innerHTML = TABLE_BODY; + const u = new URL("http://localhost/captures/"); + for (const [k, v] of Object.entries(searchParamsObj)) { + u.searchParams.set(k, v); + } + // Relative URL so jsdom updates location.search reliably (absolute href can leave search empty). + window.history.replaceState({}, "", `${u.pathname}${u.search}`); +} + +describe("table sort URL behavior", () => { + let savedURLSearchParams; + let savedURL; + + beforeAll(() => { + savedURLSearchParams = global.URLSearchParams; + savedURL = global.URL; + global.URLSearchParams = window.URLSearchParams; + global.URL = window.URL; + }); + + afterAll(() => { + global.URLSearchParams = savedURLSearchParams; + global.URL = savedURL; + }); + + describe("deprecated components.js TableManager — sort updates URL (pushState)", () => { + let DeprecatedTableManager; + + beforeAll(() => { + // eslint-disable-next-line global-require + ({ TableManager: DeprecatedTableManager } = require("../deprecated/components.js")); + }); + + afterEach(() => { + jest.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + beforeEach(() => { + mountSortPage({ sort_by: "created_at", sort_order: "desc" }); + jest.spyOn(window.history, "pushState"); + }); + + test("clicking another column sets sort_by, first order asc, page 1 in pushed URL", () => { + // eslint-disable-next-line no-new + new DeprecatedTableManager({ + tableId: "tm-table", + loadingIndicatorId: "tm-loading", + paginationContainerId: "tm-pag", + }); + const [nameHeader] = document.querySelectorAll("th.sortable"); + nameHeader.click(); + + expect(window.history.pushState).toHaveBeenCalled(); + const pushed = window.history.pushState.mock.calls.at(-1)[2]; + expect(pushed).toMatch(/sort_by=name/); + expect(pushed).toMatch(/sort_order=asc/); + expect(pushed).toMatch(/page=1/); + }); + }); +}); diff --git a/gateway/sds_gateway/static/js/__tests__/file-list.test.js b/gateway/sds_gateway/static/js/__tests__/file-list.test.js deleted file mode 100644 index 62582e290..000000000 --- a/gateway/sds_gateway/static/js/__tests__/file-list.test.js +++ /dev/null @@ -1,784 +0,0 @@ -/** - * Jest tests for file-list.js - * Tests FileListController and FileListCapturesTableManager functionality - */ - -// Mock components.js classes that file-list.js depends on -// These MUST be set up BEFORE importing file-list.js -class MockTableManager { - constructor(options) { - this.options = options; - this.showLoading = jest.fn(); - this.hideLoading = jest.fn(); - this.showError = jest.fn(); - this.attachRowClickHandlers = jest.fn(); - } -} - -class MockCapturesTableManager extends MockTableManager { - constructor(options) { - super(options); - this.resultsCountElement = null; - } - updateTable() {} - updateResultsCount() {} - renderRow() {} -} - -class MockSearchManager { - constructor(options) { - this.options = options; - } -} - -class MockModalManager { - constructor(options) { - this.options = options; - } -} - -class MockPaginationManager { - constructor(options) { - this.options = options; - } -} - -// Make these available globally (as they would be from components.js) -global.TableManager = MockTableManager; -global.CapturesTableManager = MockCapturesTableManager; -global.SearchManager = MockSearchManager; -global.ModalManager = MockModalManager; -global.PaginationManager = MockPaginationManager; - -// Also make them available on window (file-list.js uses them without global prefix) -global.window.ModalManager = MockModalManager; -global.window.SearchManager = MockSearchManager; -global.window.PaginationManager = MockPaginationManager; - -// Mock CONFIG constant (file-list.js uses it) -global.CONFIG = { - DEBOUNCE_DELAY: 300, - DEFAULT_SORT_BY: "created_at", - DEFAULT_SORT_ORDER: "desc", - ELEMENT_IDS: { - SEARCH_INPUT: "search-input", - START_DATE: "start_date", - END_DATE: "end_date", - CENTER_FREQ_MIN: "centerFreqMinInput", - CENTER_FREQ_MAX: "centerFreqMaxInput", - APPLY_FILTERS: "apply-filters-btn", - CLEAR_FILTERS: "clear-filters-btn", - ITEMS_PER_PAGE: "items-per-page", - }, -}; - -// Mock ComponentUtils -global.window.ComponentUtils = { - escapeHtml: jest.fn((str) => { - if (!str) return ""; - return String(str) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - }), - formatDateForModal: jest.fn((date) => { - if (!date) return "-"; - const d = new Date(date); - return d.toISOString().split("T")[0]; - }), -}; - -// Mock Bootstrap Dropdown -global.bootstrap.Dropdown = jest - .fn() - .mockImplementation((element, options) => ({ - show: jest.fn(), - hide: jest.fn(), - element: element, - options: options, - })); - -// NOW import the actual classes from file-list.js -// (after all dependencies are mocked) -// Use require() instead of import so it executes after mocks are set up -const { FileListController } = require("../file-list.js"); - -describe("FileListController", () => { - let fileListController; - let mockElements; - let mockTableManager; - let mockSearchManager; - let mockModalManager; - let mockPaginationManager; - - beforeEach(() => { - jest.clearAllMocks(); - - // Mock DOM elements - mockElements = { - searchInput: { - value: "", - addEventListener: jest.fn(), - }, - startDate: { value: "", addEventListener: jest.fn() }, - endDate: { value: "", addEventListener: jest.fn() }, - centerFreqMin: { value: "", addEventListener: jest.fn() }, - centerFreqMax: { value: "", addEventListener: jest.fn() }, - applyFilters: { addEventListener: jest.fn() }, - clearFilters: { addEventListener: jest.fn() }, - itemsPerPage: { value: "25", addEventListener: jest.fn() }, - sortableHeaders: [], - frequencyButton: { addEventListener: jest.fn() }, - frequencyCollapse: {}, - dateButton: { addEventListener: jest.fn() }, - dateCollapse: {}, - }; - - // Mock document methods - document.getElementById = jest.fn((id) => { - const idMap = { - "search-input": mockElements.searchInput, - start_date: mockElements.startDate, - end_date: mockElements.endDate, - centerFreqMinInput: mockElements.centerFreqMin, - centerFreqMaxInput: mockElements.centerFreqMax, - "apply-filters-btn": mockElements.applyFilters, - "clear-filters-btn": mockElements.clearFilters, - "items-per-page": mockElements.itemsPerPage, - collapseFrequency: mockElements.frequencyCollapse, - collapseDate: mockElements.dateCollapse, - }; - return idMap[id] || null; - }); - - document.querySelector = jest.fn((selector) => { - if (selector === '[data-bs-target="#collapseFrequency"]') { - return mockElements.frequencyButton; - } - if (selector === '[data-bs-target="#collapseDate"]') { - return mockElements.dateButton; - } - if (selector === "th.sortable") { - return []; - } - return null; - }); - - document.querySelectorAll = jest.fn(() => []); - - // Mock window.location - window.location = { - pathname: "/captures/", - search: "", - }; - window.history = { - pushState: jest.fn(), - }; - - // Mock URLSearchParams - window.URLSearchParams = class URLSearchParams { - constructor(search) { - this.params = new Map(); - if (search) { - const pairs = search.replace("?", "").split("&"); - for (const pair of pairs) { - const [key, value] = pair.split("="); - if (key) this.params.set(key, value || ""); - } - } - } - get(name) { - return this.params.get(name) || null; - } - set(name, value) { - this.params.set(name, value); - } - toString() { - return Array.from(this.params.entries()) - .map(([k, v]) => `${k}=${v}`) - .join("&"); - } - }; - - // Create mock managers - mockTableManager = new MockCapturesTableManager({ - tableId: "captures-table", - loadingIndicatorId: "loading-indicator", - tableContainerSelector: ".table-responsive", - resultsCountId: "results-count", - }); - - mockSearchManager = new MockSearchManager({ - searchInputId: "search-input", - searchButtonId: "search-btn", - clearButtonId: "reset-search-btn", - }); - - mockModalManager = new MockModalManager({ - modalId: "capture-modal", - modalBodyId: "capture-modal-body", - }); - - mockPaginationManager = new MockPaginationManager({ - containerId: "captures-pagination", - }); - - // Mock global classes (they would be imported from components.js) - global.ModalManager = jest.fn(() => mockModalManager); - global.SearchManager = jest.fn(() => mockSearchManager); - global.PaginationManager = jest.fn(() => mockPaginationManager); - global.CapturesTableManager = jest.fn(() => mockTableManager); - - // Also make them available on window (file-list.js uses them without global prefix) - global.window.ModalManager = global.ModalManager; - global.window.SearchManager = global.SearchManager; - global.window.PaginationManager = global.PaginationManager; - global.window.CapturesTableManager = global.CapturesTableManager; - }); - - describe("Initialization", () => { - test("should initialize with default sort values", () => { - window.location.search = ""; - fileListController = new FileListController(); - - expect(fileListController.currentSortBy).toBe("created_at"); - expect(fileListController.currentSortOrder).toBe("desc"); - }); - - test("should initialize with URL params", () => { - // Mock URLSearchParams to return the values we want - const originalURLSearchParams = window.URLSearchParams; - window.URLSearchParams = class URLSearchParams { - constructor(search) { - this.params = new Map(); - if (search) { - const pairs = search.replace("?", "").split("&"); - for (const pair of pairs) { - const [key, value] = pair.split("="); - if (key) this.params.set(key, value || ""); - } - } - } - get(name) { - return this.params.get(name) || null; - } - }; - - // Create a mock location that will be used by URLSearchParams - Object.defineProperty(window, "location", { - value: { - search: "?sort_by=name&sort_order=asc", - pathname: "/captures/", - }, - writable: true, - }); - - fileListController = new FileListController(); - - expect(fileListController.currentSortBy).toBe("name"); - expect(fileListController.currentSortOrder).toBe("asc"); - - // Restore - window.URLSearchParams = originalURLSearchParams; - }); - - test("should cache DOM elements", () => { - fileListController = new FileListController(); - - expect(fileListController.elements).toBeDefined(); - expect(fileListController.elements.searchInput).toBe( - mockElements.searchInput, - ); - expect(fileListController.elements.startDate).toBe( - mockElements.startDate, - ); - }); - - test("should initialize component managers", () => { - fileListController = new FileListController(); - - expect(global.ModalManager).toHaveBeenCalled(); - expect(global.SearchManager).toHaveBeenCalled(); - expect(global.PaginationManager).toHaveBeenCalled(); - expect(fileListController.modalManager).toBe(mockModalManager); - expect(fileListController.searchManager).toBe(mockSearchManager); - }); - }); - - describe("Search functionality", () => { - beforeEach(() => { - fileListController = new FileListController(); - }); - - test("buildSearchParams should include all filter values", () => { - mockElements.searchInput.value = "test search"; - mockElements.startDate.value = "2024-01-01"; - mockElements.endDate.value = "2024-12-31"; - mockElements.centerFreqMin.value = "1.0"; - mockElements.centerFreqMax.value = "5.0"; - - // Set userInteractedWithFrequency to true to include frequency params - fileListController.userInteractedWithFrequency = true; - - const params = fileListController.buildSearchParams(); - - expect(params.get("search")).toBe("test search"); - expect(params.get("date_start")).toBe("2024-01-01"); - expect(params.get("date_end")).toBe("2024-12-31T23:59:59"); - expect(params.get("min_freq")).toBe("1.0"); - expect(params.get("max_freq")).toBe("5.0"); - expect(params.get("sort_by")).toBe("created_at"); - expect(params.get("sort_order")).toBe("desc"); - }); - - test("buildSearchParams should handle empty values", () => { - mockElements.searchInput.value = ""; - mockElements.startDate.value = ""; - - const params = fileListController.buildSearchParams(); - - // Empty values should not be set in params - expect(params.get("search")).toBeNull(); - expect(params.get("date_start")).toBeNull(); - // But sort params should always be set - expect(params.get("sort_by")).toBe("created_at"); - expect(params.get("sort_order")).toBe("desc"); - }); - }); -}); - -describe("FileListCapturesTableManager", () => { - let tableManager; - let mockTbody; - let mockResultsCount; - - beforeEach(() => { - jest.clearAllMocks(); - - // Mock tbody element - mockTbody = { - innerHTML: "", - querySelector: jest.fn(), - querySelectorAll: jest.fn(() => []), - }; - - // Mock results count element - mockResultsCount = { - textContent: "", - }; - - document.querySelector = jest.fn((selector) => { - if (selector === "tbody") { - return mockTbody; - } - return null; - }); - - document.querySelectorAll = jest.fn(() => []); - - document.getElementById = jest.fn((id) => { - if (id === "results-count") { - return mockResultsCount; - } - return null; - }); - - // Mock ComponentUtils - global.window.ComponentUtils = { - escapeHtml: jest.fn((str) => { - if (!str) return ""; - return String(str) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - }), - formatDateForModal: jest.fn((date) => { - if (!date) return "-"; - const d = new Date(date); - return d.toISOString().split("T")[0]; - }), - }; - - // Mock Bootstrap Dropdown - global.bootstrap.Dropdown = jest.fn().mockImplementation((element) => ({ - show: jest.fn(), - hide: jest.fn(), - element: element, - })); - - // Create table manager instance - // We need to import or define the class - for now, we'll test it directly - // In a real scenario, we'd import from file-list.js - tableManager = { - resultsCountElement: mockResultsCount, - renderRow: (capture) => { - // This would be the actual renderRow implementation - const safeData = { - uuid: window.ComponentUtils.escapeHtml(capture.uuid || ""), - name: window.ComponentUtils.escapeHtml(capture.name || ""), - channel: window.ComponentUtils.escapeHtml(capture.channel || ""), - scanGroup: window.ComponentUtils.escapeHtml(capture.scan_group || ""), - captureType: window.ComponentUtils.escapeHtml( - capture.capture_type || "", - ), - captureTypeDisplay: window.ComponentUtils.escapeHtml( - capture.capture_type_display || "", - ), - topLevelDir: window.ComponentUtils.escapeHtml( - capture.top_level_dir || "", - ), - owner: window.ComponentUtils.escapeHtml(capture.owner || ""), - }; - - const nameDisplay = safeData.name || "Unnamed Capture"; - const typeDisplay = - safeData.captureTypeDisplay || safeData.captureType || "-"; - - return ` - - - - ${nameDisplay} - - - ${safeData.topLevelDir || "-"} - ${typeDisplay} - - - - - - - `; - }, - updateTable: function (captures, hasResults) { - if (!mockTbody) return; - - if (!hasResults || captures.length === 0) { - mockTbody.innerHTML = ` - - - No captures found matching your search criteria. - - - `; - return; - } - - const tableHTML = captures - .map((capture, index) => this.renderRow(capture, index)) - .join(""); - mockTbody.innerHTML = tableHTML; - - // Initialize dropdowns - this.initializeDropdowns(); - }, - updateResultsCount: function (captures, hasResults) { - if (this.resultsCountElement) { - const count = hasResults && captures ? captures.length : 0; - const pluralSuffix = count === 1 ? "" : "s"; - this.resultsCountElement.textContent = `${count} capture${pluralSuffix} found`; - } - }, - initializeDropdowns: () => { - const dropdownButtons = document.querySelectorAll(".btn-icon-dropdown"); - - if (dropdownButtons.length === 0) { - return; - } - - for (const toggle of dropdownButtons) { - if (toggle._dropdown) { - continue; - } - - const dropdownMenu = toggle.nextElementSibling; - if ( - !dropdownMenu || - !dropdownMenu.classList.contains("dropdown-menu") - ) { - continue; - } - - const dropdown = new bootstrap.Dropdown(toggle, { - container: "body", - boundary: "viewport", - popperConfig: { - modifiers: [ - { - name: "preventOverflow", - options: { - boundary: "viewport", - }, - }, - ], - }, - }); - - toggle.addEventListener("show.bs.dropdown", () => { - if (dropdownMenu?.classList.contains("dropdown-menu")) { - document.body.appendChild(dropdownMenu); - } - }); - - toggle._dropdown = dropdown; - } - }, - }; - }); - - describe("updateTable", () => { - test("should render empty state when no results", () => { - tableManager.updateTable([], false); - - expect(mockTbody.innerHTML).toContain("No captures found"); - expect(mockTbody.innerHTML).toContain('colspan="5"'); - }); - - test("should render captures when results exist", () => { - const captures = [ - { - uuid: "test-uuid-1", - name: "Test Capture 1", - capture_type: "drf", - capture_type_display: "DRF", - top_level_dir: "/test/dir", - channel: "1", - owner: "test@example.com", - created_at: "2024-01-01T00:00:00Z", - }, - { - uuid: "test-uuid-2", - name: "Test Capture 2", - capture_type: "rh", - capture_type_display: "RH", - top_level_dir: "/test/dir2", - channel: "2", - owner: "test2@example.com", - created_at: "2024-01-02T00:00:00Z", - }, - ]; - - tableManager.updateTable(captures, true); - - expect(mockTbody.innerHTML).toContain("Test Capture 1"); - expect(mockTbody.innerHTML).toContain("Test Capture 2"); - expect(mockTbody.innerHTML).toContain("test-uuid-1"); - expect(mockTbody.innerHTML).toContain("test-uuid-2"); - }); - - test("should call initializeDropdowns after rendering", () => { - const spy = jest.spyOn(tableManager, "initializeDropdowns"); - const captures = [ - { - uuid: "test-uuid-1", - name: "Test Capture", - capture_type: "drf", - top_level_dir: "/test", - channel: "1", - owner: "test@example.com", - }, - ]; - - tableManager.updateTable(captures, true); - - expect(spy).toHaveBeenCalled(); - }); - }); - - describe("updateResultsCount", () => { - test("should update results count with correct pluralization", () => { - const captures = [{ uuid: "1" }]; - tableManager.updateResultsCount(captures, true); - - expect(mockResultsCount.textContent).toBe("1 capture found"); - }); - - test("should handle plural form", () => { - const captures = [{ uuid: "1" }, { uuid: "2" }]; - tableManager.updateResultsCount(captures, true); - - expect(mockResultsCount.textContent).toBe("2 captures found"); - }); - - test("should handle no results", () => { - tableManager.updateResultsCount([], false); - - expect(mockResultsCount.textContent).toBe("0 captures found"); - }); - }); - - describe("initializeDropdowns", () => { - test("should initialize Bootstrap dropdowns", () => { - const mockDropdownMenu = { - classList: { - contains: jest.fn(() => true), - }, - }; - const mockToggle = { - _dropdown: null, - nextElementSibling: mockDropdownMenu, - addEventListener: jest.fn(), - }; - - document.querySelectorAll = jest.fn(() => [mockToggle]); - - // Mock document.body properly - if (!document.body) { - document.body = document.createElement("body"); - } - document.body.appendChild = jest.fn(); - - tableManager.initializeDropdowns(); - - expect(global.bootstrap.Dropdown).toHaveBeenCalled(); - expect(mockToggle._dropdown).toBeDefined(); - expect(mockToggle.addEventListener).toHaveBeenCalledWith( - "show.bs.dropdown", - expect.any(Function), - ); - }); - - test("should skip already initialized dropdowns", () => { - const mockToggle = { - _dropdown: { show: jest.fn() }, - nextElementSibling: null, - addEventListener: jest.fn(), - }; - - document.querySelectorAll = jest.fn(() => [mockToggle]); - - tableManager.initializeDropdowns(); - - expect(global.bootstrap.Dropdown).not.toHaveBeenCalled(); - }); - - test("should handle missing dropdown menu", () => { - const mockToggle = { - _dropdown: null, - nextElementSibling: null, - addEventListener: jest.fn(), - }; - - document.querySelectorAll = jest.fn(() => [mockToggle]); - - tableManager.initializeDropdowns(); - - // Should not initialize dropdown if nextElementSibling is null - expect(global.bootstrap.Dropdown).not.toHaveBeenCalled(); - }); - }); - - describe("renderRow", () => { - test("should render row with all required columns", () => { - const capture = { - uuid: "test-uuid", - name: "Test Capture", - capture_type: "drf", - capture_type_display: "DRF", - top_level_dir: "/test/dir", - channel: "1", - owner: "test@example.com", - created_at: "2024-01-01T00:00:00Z", - }; - - const html = tableManager.renderRow(capture, 0); - - expect(html).toContain("test-uuid"); - expect(html).toContain("Test Capture"); - expect(html).toContain("/test/dir"); - expect(html).toContain('headers="name-header"'); - expect(html).toContain('headers="top-level-dir-header"'); - expect(html).toContain('headers="type-header"'); - expect(html).toContain('headers="created-header"'); - expect(html).toContain('headers="actions-header"'); - }); - - test("should escape HTML in capture data", () => { - const capture = { - uuid: "test-uuid", - name: "", - capture_type: "drf", - top_level_dir: "/test/dir", - channel: "1", - owner: "test@example.com", - }; - - const html = tableManager.renderRow(capture, 0); - - expect(html).not.toContain(" + {% endblock javascript %} diff --git a/gateway/sds_gateway/templates/users/components/capture_list_modals_fragment.html b/gateway/sds_gateway/templates/users/components/capture_list_modals_fragment.html new file mode 100644 index 000000000..0e6f53536 --- /dev/null +++ b/gateway/sds_gateway/templates/users/components/capture_list_modals_fragment.html @@ -0,0 +1,9 @@ +{% comment %} +Per-capture modals for AJAX list refresh (share + web download). +{% endcomment %} +{% for cap in captures %} + {% if cap.capture %} + {% include "users/partials/share_modal.html" with item=cap.capture item_type="capture" %} + {% include "users/partials/web_download_modal.html" with item=cap.capture item_type="capture" %} + {% endif %} +{% endfor %} diff --git a/gateway/sds_gateway/templates/users/components/capture_list_table_fragment.html b/gateway/sds_gateway/templates/users/components/capture_list_table_fragment.html new file mode 100644 index 000000000..1940cf89f --- /dev/null +++ b/gateway/sds_gateway/templates/users/components/capture_list_table_fragment.html @@ -0,0 +1,4 @@ +{% comment %} +Capture list table + pagination only (AJAX fragment for ListRefreshManager). +{% endcomment %} +{% include "users/partials/captures_page_table.html" %} diff --git a/gateway/sds_gateway/templates/users/dataset_list.html b/gateway/sds_gateway/templates/users/dataset_list.html index 4ead29d05..55b8c877e 100644 --- a/gateway/sds_gateway/templates/users/dataset_list.html +++ b/gateway/sds_gateway/templates/users/dataset_list.html @@ -40,15 +40,18 @@

Datasets

+ + + - - - + + + + + {# djlint:off #} + + {# djlint:on #} + - + + {# djlint:off #} - - {# Fallback modal helpers provided by components.js; inline duplicates removed #} - - + {{ block.super }} + + + + + + + @@ -526,6 +529,21 @@ - - - + {% endblock javascript %} diff --git a/gateway/sds_gateway/templates/users/group_captures.html b/gateway/sds_gateway/templates/users/group_captures.html index f9f665208..af12b6fea 100644 --- a/gateway/sds_gateway/templates/users/group_captures.html +++ b/gateway/sds_gateway/templates/users/group_captures.html @@ -115,6 +115,7 @@
{% block extra_js %} + diff --git a/gateway/sds_gateway/templates/users/partials/captures_page_table.html b/gateway/sds_gateway/templates/users/partials/captures_page_table.html index 94b9bbc3e..2b7b73a22 100644 --- a/gateway/sds_gateway/templates/users/partials/captures_page_table.html +++ b/gateway/sds_gateway/templates/users/partials/captures_page_table.html @@ -219,12 +219,13 @@ {% if captures and captures.paginator.num_pages > 1 %} -