Captures
+Searching captures...
+diff --git a/.cursor/rules/django_javascript_implementation.mdc b/.cursor/rules/django_javascript_implementation.mdc index 03dd4a6d8..d4a0db04c 100644 --- a/.cursor/rules/django_javascript_implementation.mdc +++ b/.cursor/rules/django_javascript_implementation.mdc @@ -13,6 +13,10 @@ alwaysApply: true - Classes should follow DRY principles and avoid duplicating the functionality of existing classes. - Javascript added to existing templates should ONLY call existing classes to initialize them so their functions are available to run in the template. - Ignore and NEVER update files in the `deprecated/` folder. +- Differentiation of code use by asset type should be configuration-driven, NOT by the method logic-driven. +- Prefer more generic method implementations over specific, per-case basis implementations which lead to bloat. +- If you notice multiple uses of the same block of code, consider adding a utils or manager method/class to handle that instead of repeating the same code everywhere. +- When creating/naming a method, check if a method by the same name/function/use already exists. If so, point it out and suggest refactoring original method to be more generic. ## Django Template Structure diff --git a/.cursor/skills/jest-test-writing/SKILL.md b/.cursor/skills/jest-test-writing/SKILL.md new file mode 100644 index 000000000..e8a3ac68e --- /dev/null +++ b/.cursor/skills/jest-test-writing/SKILL.md @@ -0,0 +1,126 @@ +--- +name: jest-test-writing +description: Writes and refactors Jest unit tests for browser JS (jsdom), emphasizing behavior-focused tests, shared mocks, and repo conventions. Use when adding or updating tests under gateway/sds_gateway/static/js, fixing failing Jest runs, or when the user asks for Jest test best practices. +--- + +# Jest test writing (SDS gateway static JS) + +## When to apply + +- New or changed code under `gateway/sds_gateway/static/js/` +- User asks for Jest patterns, test structure, or coverage for frontend managers/handlers +- CI/local failure from `npm test` in `gateway/` + +## Run tests + +From `gateway/`: + +```bash +npm test +npm run test:watch +npm run test:coverage +``` + +Config: `sds_gateway/static/js/tests-config/jest.config.js` (jsdom, `clearMocks` + `restoreMocks`). + +## File placement + +| Rule | Detail | +|------|--------| +| Co-locate | `SomeManager.js` → `__tests__/SomeManager.test.js` in the same folder | +| Naming | `*.test.js` or `*.spec.js` (see `testMatch` in jest config) | +| Skip | Never add tests for `deprecated/` | + +## What to test (and what not to) + +**Do** + +- Public methods and user-visible outcomes (DOM updates, calls to `DOMUtils`, `APIClient`, Bootstrap modal show/hide) +- Branches that encode product rules (permissions denied, missing modal, API error responses) +- Async flows: `await` the method under test, then assert mocks/callbacks + +**Avoid** + +- Asserting private helpers or internal call order unless order is the contract +- Tests that only `expect(x).toBeDefined()` or mirror the implementation line-for-line +- Copy-pasting 50-line mock trees—extend shared helpers instead + +## Shared infrastructure (use first) + +Read and reuse before inventing mocks: + +| Resource | Path | +|----------|------| +| DOM/API/permissions helpers | `sds_gateway/static/js/tests-config/testHelpers.js` | +| Action-manager setups | `sds_gateway/static/js/__tests__/helpers/actionTestMocks.js` | +| Global env | `sds_gateway/static/js/tests-config/jest.setup.js` | + +Common helpers: + +- `setupStandardUnitTest({ useModalDomUtils, apiClientOverrides, window, getElementByIdMap })` — resets mocks, stubs `document`, sets `window.DOMUtils` +- `createMockDOMUtils` / `createMockDOMUtilsWithModals` +- `createMockAPIClient`, `createMockPermissionsManager` +- `flushMicrotasks()` — after fire-and-forget promises tied to `setTimeout(0)` +- `installDocumentGetByIdMap({ id: element })` when code uses `getElementById` + +For Download/Share/Versioning action tests, prefer `setupDownloadActionTestEnvironment` / patterns in `actionTestMocks.js`. + +## Structure template + +```javascript +/** + * Jest tests for TargetClass + */ + +import { TargetClass } from "../TargetClass.js"; +import { setupStandardUnitTest, flushMicrotasks } from "../../tests-config/testHelpers.js"; + +describe("TargetClass", () => { + beforeEach(() => { + setupStandardUnitTest({ /* opts */ }); + // Extra per-suite: bootstrap, document.body, window globals + }); + + test("describes behavior in plain language", async () => { + // arrange → act → assert + }); +}); +``` + +Use `require()` for helpers if the file already uses CommonJS; stay consistent within a file. + +## Mocking conventions (this repo) + +1. **Bootstrap modals** — set `global.bootstrap.Modal` and `Modal.getInstance` in `beforeEach` (see `ModalManager.test.js`, `actionTestMocks.installBootstrapModalMocks`). +2. **`window.DOMUtils`** — via `setupStandardUnitTest` or explicit assign; use `.mockResolvedValue()` for async UI helpers. +3. **`document`** — prefer `installDocumentGetByIdMap` or minimal stubs from testHelpers; use real `document.createElement` / `body.innerHTML` only when testing DOM wiring. +4. **Module mocks** — `jest.mock("../Dependency.js")` at top level; factory returns minimal surface the unit needs. +5. **Fetch / API** — mock `APIClient` methods or `window.fetch` with `createMockFetchResponse` / resolved shapes `{ success: true }` matching production handlers. + +## Async + +- Prefer `async/await` in tests over bare `.then()`. +- If code schedules work on microtasks/macrotasks without returning a promise, `await flushMicrotasks()` before assertions. +- Fake timers: use `jest.useFakeTimers()` only when testing timer logic; restore in `afterEach`. + +## Assertions + +- One logical behavior per test name (readable as a spec sentence). +- Assert on **arguments** to mocks when the contract is “calls X with Y” (`toHaveBeenCalledWith`). +- For errors: assert rejected promise or that `showError` / `showModalError` was invoked with expected message. + +## Coverage + +Global thresholds (70%) in jest config. New code should not drop coverage on touched files; add tests for new branches rather than excluding files. + +## Checklist before finishing + +- [ ] Test file lives in `__tests__/` next to source +- [ ] Reused `testHelpers` / `actionTestMocks` where applicable +- [ ] `beforeEach` resets DOM/globals needed for isolation +- [ ] Tests describe behavior, not implementation trivia +- [ ] `npm test` passes from `gateway/` + +## More detail + +See [reference.md](reference.md) for helper exports and example patterns. diff --git a/.cursor/skills/jest-test-writing/reference.md b/.cursor/skills/jest-test-writing/reference.md new file mode 100644 index 000000000..7ed9851a7 --- /dev/null +++ b/.cursor/skills/jest-test-writing/reference.md @@ -0,0 +1,70 @@ +# Jest helpers reference (gateway static JS) + +## testHelpers.js (primary exports) + +| Export | Use when | +|--------|----------| +| `setupStandardUnitTest` | Default start of unit test `beforeEach` | +| `createMockDOMUtils` | Class uses `window.DOMUtils` without modals | +| `createMockDOMUtilsWithModals` | Modal loading/error/open/close | +| `createMockAPIClient` | Inject or assign API client mock | +| `createMockPermissionsManager` | Permission-gated actions | +| `installDocumentGetByIdMap` | Code looks up specific element IDs | +| `installMinimalDocumentMocks` / `installDocumentQueryStubs` | Low-level DOM stubs | +| `mergeWindowMocks` | Attach globals (`downloadActionManager`, etc.) | +| `flushMicrotasks` | Settle `setTimeout(0)` chains | +| `createMockFetchResponse` / `mockFetchResolved` | Raw `fetch` tests | +| `installCsrfMetaToken` | `APIClient` / CSRF paths | +| `createPublishingSubmitDomFixture` | Publish flow DOM | +| `createDefaultAssetSearchConfig` | Search handler tests | + +`setupStandardUnitTest` always calls `jest.clearAllMocks()` and installs document stubs; pass `useModalDomUtils: true` for action/modal suites. + +## actionTestMocks.js + +| Export | Use when | +|--------|----------| +| `setupDownloadActionTestEnvironment` | Download action manager tests | +| `installBootstrapModalMocks` | Any Bootstrap 5 modal interaction | +| `createMockDownloadPermissions` | Download permission checks | +| `createDefaultShareActionConfig` | Share action defaults | + +## Example: permission denied + +```javascript +setupStandardUnitTest({ + apiClientOverrides: { post: jest.fn().mockResolvedValue({ success: false, message: "Forbidden" }) }, +}); +const manager = new SomeActionManager({ permissions: { canShare: false } }); +await manager.submit(); +expect(window.DOMUtils.showError).toHaveBeenCalled(); +``` + +## Example: DOM id map + +```javascript +const form = createMockFormElement(); +setupStandardUnitTest({ + getElementByIdMap: { "share-form": form }, +}); +``` + +## jest.setup.js + +Provides baseline `document`, `window`, `fetch`, storage, and timers. Do not duplicate full window mocks in every file—override only what the test needs in `beforeEach`. + +## Import paths + +From `actions/__tests__/Foo.test.js`: + +```javascript +const { setupStandardUnitTest } = require("../../tests-config/testHelpers.js"); +``` + +From `actions/details/__tests__/Bar.test.js`: + +```javascript +const { setupStandardUnitTest } = require("../../../tests-config/testHelpers.js"); +``` + +Adjust `../` depth to reach `tests-config/`. diff --git a/.github/workflows/gwy-code-quality.yaml b/.github/workflows/gwy-code-quality.yaml index 00c8eb2bf..aa40f62bf 100644 --- a/.github/workflows/gwy-code-quality.yaml +++ b/.github/workflows/gwy-code-quality.yaml @@ -58,6 +58,10 @@ jobs: key: prek-gateway-${{ hashFiles('gateway/.pre-commit-config.yaml') }} path: ~/.cache/prek/ + - name: Install gateway frontend dependencies + working-directory: ./gateway + run: npm ci + - name: Install hooks working-directory: ./gateway run: uv run --extra local prek install --install-hooks diff --git a/.github/workflows/sdk-code-quality.yaml b/.github/workflows/sdk-code-quality.yaml index 21d5443f4..d33a2bc44 100644 --- a/.github/workflows/sdk-code-quality.yaml +++ b/.github/workflows/sdk-code-quality.yaml @@ -54,6 +54,10 @@ jobs: key: prek-${{ hashFiles('.pre-commit-config.yaml') }} path: ~/.cache/prek/ + - name: Install gateway frontend dependencies + working-directory: ./gateway + run: npm ci + - name: Install hooks working-directory: ./sdk run: uv run --dev prek install --install-hooks diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c8c6962b2..58e8f055a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -107,6 +107,12 @@ repos: language: system pass_filenames: false # run once per commit, not per file types: [ python ] + - id: fallow-cross-file-dupes + name: fallow cross-file dupes (gateway static js) + entry: bash gateway/scripts/fallow-cross-file-dupes.sh + language: system + pass_filenames: false + files: ^gateway/sds_gateway/static/js/.*\.js$ # sets up .pre-commit-ci.yaml to ensure pre-commit dependencies stay up to date ci: diff --git a/gateway/.fallowrc.json b/gateway/.fallowrc.json new file mode 100644 index 000000000..ff1e56d05 --- /dev/null +++ b/gateway/.fallowrc.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json", + "ignorePatterns": [ + "sds_gateway/static/js/deprecated/**", + "compose/**", + "sds_gateway/static/css/**" + ], + "ignoreExportsUsedInFile": true +} diff --git a/gateway/.gitignore b/gateway/.gitignore index 76db6d0a6..c310d5cbb 100644 --- a/gateway/.gitignore +++ b/gateway/.gitignore @@ -278,6 +278,8 @@ tags dump.rdb ### Project template +.fallow/ + sds_gateway/media/ .pytest_cache/ diff --git a/gateway/package-lock.json b/gateway/package-lock.json index a9685f574..eaabd5292 100644 --- a/gateway/package-lock.json +++ b/gateway/package-lock.json @@ -21,6 +21,7 @@ "babel-loader": "^9.2.1", "bootstrap": "^5.3.3", "css-loader": "^6.5.1", + "fallow": "^2.74.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "mini-css-extract-plugin": "^2.9.1", @@ -3092,6 +3093,118 @@ "node": ">=10.0.0" } }, + "node_modules/@fallow-cli/darwin-arm64": { + "version": "2.74.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/darwin-arm64/-/darwin-arm64-2.74.0.tgz", + "integrity": "sha512-zpxOjs8YdVgq82JSQbjph2Zzp8dlNBLC3vWcyAsmoUlnQxUTfmQbCM6PMugrs+7iMpp1IgTIjVXfZsnbua+L1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@fallow-cli/darwin-x64": { + "version": "2.74.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/darwin-x64/-/darwin-x64-2.74.0.tgz", + "integrity": "sha512-0QCR3fsnbhmaHLLIMdhEMeoyvNAP6oxNAyVA3Te3eGjVum7yVy9P7FzN44D1pPerb1qriG4MsJOOfT8Hix/9jg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@fallow-cli/linux-arm64-gnu": { + "version": "2.74.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/linux-arm64-gnu/-/linux-arm64-gnu-2.74.0.tgz", + "integrity": "sha512-iGsRcR9+fd5WpybXFTBjw1mJigePJgdqp8T6NAqwgz0DgBUB1s8PpAnOUQRLtshfNAhsJdxZTakFuNdV8R7VTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@fallow-cli/linux-arm64-musl": { + "version": "2.74.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/linux-arm64-musl/-/linux-arm64-musl-2.74.0.tgz", + "integrity": "sha512-9uqyXjhp5kRwJxucRxChN4/YmuDyuoPx2iKzqW73JgU08BGIJyY5Olq4B594MmqRrjwmsFm7jFKT8x9BbSvWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@fallow-cli/linux-x64-gnu": { + "version": "2.74.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/linux-x64-gnu/-/linux-x64-gnu-2.74.0.tgz", + "integrity": "sha512-K2ghYaXH8kmeos9bAKG7+HQLU7Y/hbm/+p9ZyTD28U/S/PdHtiny8KmuCCC7uisy9nUmTnyy59YX///3lkIqxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@fallow-cli/linux-x64-musl": { + "version": "2.74.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/linux-x64-musl/-/linux-x64-musl-2.74.0.tgz", + "integrity": "sha512-3YSBIZLEXs47HX8HI2wr+C0QAuqF3eeB+3uDIijXgF0guE58iUrzFLOoFAqEv4cuv5WTvD/PtMllWyCPWkiIaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@fallow-cli/win32-arm64-msvc": { + "version": "2.74.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/win32-arm64-msvc/-/win32-arm64-msvc-2.74.0.tgz", + "integrity": "sha512-wjCHg6iKjTwRg2AJ2wl2ryil5XAtxNwI1JbxC7I32uD4mZUnBqDNkijecC61ZGg18JpE3vsumwzqcZbQgPtwdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@fallow-cli/win32-x64-msvc": { + "version": "2.74.0", + "resolved": "https://registry.npmjs.org/@fallow-cli/win32-x64-msvc/-/win32-x64-msvc-2.74.0.tgz", + "integrity": "sha512-y5AsMPWWlI3gTrsFA+7ggeAMUbQjBQE15i+wu9LLlQs43RQdfY9DylYcMVw8gcQmxx0d+PcL/1WyJYdr8CeqSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -6577,7 +6690,6 @@ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -7072,6 +7184,35 @@ "dev": true, "license": "MIT" }, + "node_modules/fallow": { + "version": "2.74.0", + "resolved": "https://registry.npmjs.org/fallow/-/fallow-2.74.0.tgz", + "integrity": "sha512-Ym1hY5E/5hex0THEfjMrJ0tDPRc0Ax4q+rS8EeVprpG/etpkOa6hSeWktyuG+Bhj7E+PotHg8vrATEGQpNo6qw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "2.1.2" + }, + "bin": { + "fallow": "bin/fallow", + "fallow-lsp": "bin/fallow-lsp", + "fallow-mcp": "bin/fallow-mcp" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@fallow-cli/darwin-arm64": "2.74.0", + "@fallow-cli/darwin-x64": "2.74.0", + "@fallow-cli/linux-arm64-gnu": "2.74.0", + "@fallow-cli/linux-arm64-musl": "2.74.0", + "@fallow-cli/linux-x64-gnu": "2.74.0", + "@fallow-cli/linux-x64-musl": "2.74.0", + "@fallow-cli/win32-arm64-msvc": "2.74.0", + "@fallow-cli/win32-x64-msvc": "2.74.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/gateway/package.json b/gateway/package.json index b025607e5..8c28b4b49 100644 --- a/gateway/package.json +++ b/gateway/package.json @@ -2,6 +2,7 @@ "name": "sds_gateway", "version": "0.0.1", "devDependencies": { + "fallow": "^2.74.0", "@babel/core": "^7.25.2", "@babel/plugin-transform-runtime": "^7.25.4", "@babel/preset-env": "^7.25.4", @@ -50,6 +51,8 @@ "test": "jest --config sds_gateway/static/js/tests-config/jest.config.js", "test:watch": "jest --config sds_gateway/static/js/tests-config/jest.config.js --watch", "test:coverage": "jest --config sds_gateway/static/js/tests-config/jest.config.js --coverage", - "test:ci": "jest --config sds_gateway/static/js/tests-config/jest.config.js --ci --coverage --watchAll=false" + "test:ci": "jest --config sds_gateway/static/js/tests-config/jest.config.js --ci --coverage --watchAll=false", + "fallow": "fallow --summary", + "fallow:static-js": "node scripts/fallow-static-js-dead-code.cjs" } } diff --git a/gateway/scripts/fallow-cross-file-dupes.sh b/gateway/scripts/fallow-cross-file-dupes.sh new file mode 100755 index 000000000..6ac01cf41 --- /dev/null +++ b/gateway/scripts/fallow-cross-file-dupes.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +npx fallow dupes --format json -q | jq -e ' + [ (.clone_groups // .dupes.clone_groups // [])[] + | select((.instances | map(.file) | unique | length) > 1) + ] | length == 0 +' >/dev/null + +echo "No cross-file clone groups detected." \ No newline at end of file diff --git a/gateway/scripts/fallow-static-js-dead-code.cjs b/gateway/scripts/fallow-static-js-dead-code.cjs new file mode 100644 index 000000000..b39538b53 --- /dev/null +++ b/gateway/scripts/fallow-static-js-dead-code.cjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/** + * Runs `fallow dead-code` with `--file` for each non-deprecated `.js` under + * `sds_gateway/static/js/` so reported issues are limited to that tree while + * the full project graph (webpack + templates) still resolves usage. + */ +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const root = path.join(__dirname, ".."); +const base = path.join(root, "sds_gateway/static/js"); +const deprecated = path.join(base, "deprecated") + path.sep; + +function walk(dir, out) { + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, ent.name); + if (p.startsWith(deprecated)) continue; + if (ent.isDirectory()) walk(p, out); + else if (p.endsWith(".js")) out.push(path.relative(root, p)); + } +} + +const files = []; +walk(base, files); +const args = ["dead-code"]; +for (const f of files) { + args.push("--file", f); +} +const bin = path.join(root, "node_modules", ".bin", "fallow"); +const r = spawnSync(bin, args, { cwd: root, stdio: "inherit" }); +process.exit(r.status === null ? 1 : r.status); diff --git a/gateway/sds_gateway/api_methods/serializers/capture_serializers.py b/gateway/sds_gateway/api_methods/serializers/capture_serializers.py index 182ad1ef7..da6b8ea9e 100644 --- a/gateway/sds_gateway/api_methods/serializers/capture_serializers.py +++ b/gateway/sds_gateway/api_methods/serializers/capture_serializers.py @@ -15,6 +15,7 @@ from sds_gateway.api_methods.helpers.index_handling import retrieve_indexed_metadata from sds_gateway.api_methods.models import Capture from sds_gateway.api_methods.models import CaptureType +from sds_gateway.api_methods.models import PermissionLevel from sds_gateway.api_methods.models import DEPRECATEDPostProcessedData from sds_gateway.api_methods.models import File from sds_gateway.api_methods.models import ItemType @@ -115,6 +116,7 @@ class CaptureGetSerializer(serializers.ModelSerializer[Capture]): share_permissions = serializers.SerializerMethodField() is_shared = serializers.SerializerMethodField() is_shared_with_me = serializers.SerializerMethodField() + permission_level = serializers.SerializerMethodField() capture_props = serializers.SerializerMethodField() files = serializers.SerializerMethodField() total_file_size = serializers.SerializerMethodField() @@ -169,6 +171,27 @@ def get_is_shared(self, capture: Capture) -> bool: """ return check_if_shared(capture.uuid, ItemType.CAPTURE) + def get_permission_level(self, capture: Capture) -> PermissionLevel | None: + """Get the current user's permission level for this capture.""" + request = self.context.get("request") + if not request or not hasattr(request, "user"): + return None + + # Check if user is the owner + if capture.owner == request.user: + return PermissionLevel.OWNER + + # Check for shared permissions + permission = UserSharePermission.objects.filter( + shared_with=request.user, + item_type=ItemType.CAPTURE, + item_uuid=capture.uuid, + is_enabled=True, + is_deleted=False, + ).first() + + return permission.permission_level if permission else None + def get_files(self, capture: Capture) -> ReturnList[File]: """Get the files for the capture. diff --git a/gateway/sds_gateway/static/css/file-list.css b/gateway/sds_gateway/static/css/file-list.css index 193b27adb..1caa7366e 100644 --- a/gateway/sds_gateway/static/css/file-list.css +++ b/gateway/sds_gateway/static/css/file-list.css @@ -344,11 +344,40 @@ body { transform: scale(1.15); } +/* Capture list table column widths (matches capture_list_table_row.html) */ +.capture-list-table { + table-layout: fixed; + width: 100%; +} + +.capture-list-table .capture-col-select { + width: 3rem; +} + +.capture-list-table .capture-col-name { + width: 34%; +} + +.capture-list-table .capture-col-directory { + width: 22%; +} + +.capture-list-table .capture-col-type { + width: 14%; +} + +.capture-list-table .capture-col-created { + width: 20%; +} + +.capture-list-table .capture-col-actions { + width: 7rem; +} + /* ================================ Modal Styling ================================ */ .modal-dialog { - max-width: 600px; margin: 1.75rem auto; } 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 ` -
Searching captures...
+{{ no_assets_message }}
++ Capture Type: + {{ capture.capture_type_display|default:capture.capture_type|default:"N/A" }} +
++ Origin: + {{ capture.origin|default:"N/A" }} +
++ Owner: + {{ owner_display }} +
++ Scan Group: + {{ capture.scan_group|default:"N/A" }} +
++ Dataset: + {{ dataset_display }} +
++ Is Public: + {{ is_public_yesno }} +
++ Top Level Directory: + {{ capture.top_level_dir|default:"N/A" }} +
++ Center Frequency: + {{ center_frequency_display }} +
+
+ Created At:
+
+ {{ capture.formatted_created_at|default:capture.created_at }}
+
+ Updated At:
+
+ {{ capture.updated_at }}
+
+ {{ row.label }}: {{ row.value }} +
+ {% endfor %} + {% else %} + No metadata available + {% endif %} ++ Number of Files: + {{ files_count }} +
++ Total Size: + {{ total_size|filesizeformat }} +
+{{ dataset.name }} (v{{ dataset.version }})
+ ++ {% if dataset.status == "final" %} + Final + {% elif dataset.status == "draft" %} + Draft + {% else %} + Unknown + {% endif %} +
++ {% if dataset.created_at %} + {{ dataset.created_at }} + {% else %} + — + {% endif %} +
++ {% if dataset_updated_at %} + {% localtime on %} + {{ dataset_updated_at|date:"M j, Y, g:i A" }} + {% endlocaltime %} + {% else %} + — + {% endif %} +
+{{ dataset.description|default:"No description provided" }}
+{{ statistics.total_files }}
+{{ statistics.captures }}
+{{ statistics.artifacts }}
+{{ statistics.total_size|filesizeformat }}
+| + | Name | +Type | +Size | +Created At | +
|---|
| - Dataset Name - | -- Author - | -- Created At - | -Actions | -
|---|
- It looks like you don't have any datasets yet. To get started, create a new dataset from your captures and files. -
-| - Dataset Name - | -- Author - | -- Created At - | -Actions | -
|---|---|---|---|
| - {% if dataset.is_owner %} - {{ dataset.name }} - {% else %} - {{ dataset.name }} - {% endif %} - {% if dataset.status == 'draft' %} - {{ dataset.status_display }} - {% endif %} - {% if dataset.is_public %}{% endif %} - {% if dataset.is_shared_with_me %} - - - - {% endif %} - | -- {% if dataset.authors %} - {% for author in dataset.authors %} - {% if forloop.counter <= 2 %} - {% if author.name %} - {% if author.orcid_id %} - - {{ author.name }} - - - {% else %} - {{ author.name }} - {% endif %} - {% if not forloop.last and forloop.counter < 2 and dataset.authors|length > 1 %},{% endif %} - {% else %} - {{ author }} - {% if not forloop.last and forloop.counter < 2 and dataset.authors|length > 1 %},{% endif %} - {% endif %} - {% endif %} - {% endfor %} - {% if dataset.authors|length > 2 %}...{% endif %} - {% else %} - - - {% endif %} - | -
- {% if dataset.dataset.created_at %}
- {% localtime on %}
-
- {{ dataset.dataset.created_at|date:"Y-m-d" }}
- {{ dataset.dataset.created_at|date:"H:i:s T" }}
-
- {% endlocaltime %}
- {% else %}
- -
- {% endif %}
- |
-
-
-
-
-
- |
-