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 @@ File Preview
-
-
-
+
{% 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 %}
-