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/gateway-static-js-refactor/SKILL.md b/.cursor/skills/gateway-static-js-refactor/SKILL.md new file mode 100644 index 000000000..842de697d --- /dev/null +++ b/.cursor/skills/gateway-static-js-refactor/SKILL.md @@ -0,0 +1,142 @@ +--- +name: gateway-static-js-refactor +description: Plans and executes refactors of SDS gateway browser JavaScript (gateway/sds_gateway/static/js) for brevity, lower complexity, parity, and reviewability. Uses Fallow metrics, Django template/RenderHTMLFragment rendering, DRY reuse, and django_javascript_implementation rules. Use for large JS refactors, reducing duplication, moving HTML out of JS, or when the user asks for refactor scenarios or fallow checks. +--- + +# Gateway static JS refactor + +## When to apply + +- Refactors under `gateway/sds_gateway/static/js/` (especially managers/handlers in `actions/`, `core/`, `dataset/`, `search/`, `share/`, `upload/`, `visualizations/`) +- User wants **scenarios**, a **phased plan**, or **metrics before/after** +- Removing inline HTML strings, cross-file duplication, or high-complexity hotspots +- Aligning new behavior with existing classes instead of one-off helpers + +**Out of scope:** `deprecated/` (never edit). For tests after refactor, use [jest-test-writing](../jest-test-writing/SKILL.md). + +## Optimization goals (in order) + +1. **Functionality parity** — same user-visible behavior, API contracts, and error handling intent +2. **Human reviewability** — small, ordered commits or phases; each diff explainable in one paragraph +3. **Low method complexity** — shallow nesting, early returns, single responsibility +4. **Brevity** — fewer lines only when clarity is preserved (not golf) + +## Mandatory conventions + +Follow `.cursor/rules/django_javascript_implementation.mdc` in full. Non-negotiables for refactors: + +| Area | Rule | +|------|------| +| JS location | Logic in `.js` files under `static/js/` subfolders; templates only **initialize** classes | +| New files | Ask before new templates; ask before new `.js` files (purpose + function list) | +| New classes | Own file in a subfolder (`actions/`, `core/`, …), not base `js/` | +| HTML | Dynamic markup → `templates/.../components/*.html` + `POST /users/render-html/` via `APIClient` / `DOMUtils`; **never** build HTML fragments in JS | +| Asset types | Configuration-driven (`constants/`, config objects), not `if (type === 'capture')` sprawl | +| Reuse | Grep for existing method/class names; **extend or generalize** before adding parallel APIs | + +## Workflow + +Copy and track: + +``` +Refactor progress: +- [ ] Baseline (fallow + identify targets) +- [ ] Propose 2–4 scenarios (user picks or hybrid) +- [ ] Implement phase 1 (smallest parity-preserving slice) +- [ ] Tests + fallow + cross-file dupes +- [ ] Implement remaining phases +- [ ] Final metrics + review notes +``` + +### 1. Baseline (always run from `gateway/`) + +```bash +npm run fallow +npx fallow health --format human +npx fallow dupes --format human +npm run fallow:static-js +bash scripts/fallow-cross-file-dupes.sh +``` + +For work on a branch, scope noise: + +```bash +npx fallow audit --changed-since main --format human +``` + +Record: worst complexity hotspots, clone groups touching your files, new dead exports. + +Details: [fallow-gateway.md](fallow-gateway.md). + +### 2. Discover reuse (before writing code) + +- Search `static/js` for the same DOM ids, `render-html` templates, `ListRefreshManager`, `DOMUtils` helpers, and action manager patterns +- Check `constants/` (`detailsModalConfig`, `FileListConfig`, permission levels) +- Prefer widening an existing util/manager over a feature-specific function + +### 3. Propose refactor scenarios + +Present **2–4 options** with tradeoffs. Typical scenario types: + +| Scenario | Best when | Risk | +|----------|-----------|------| +| **Extract + name** | One 200+ line method or deep nesting | Low if tests exist | +| **Consolidate duplicates** | Fallow `dupes` or copy-pasted blocks across files | Medium — watch cross-file pre-commit | +| **Config extraction** | Repeated asset-type branches | Low if config matches existing constants style | +| **Template migration** | Large `` `...html...` `` or string concatenation in JS | Medium — needs component template + view context | +| **Delegate to existing API** | Reinventing list refresh, modals, permissions | Low | +| **Split class by concern** | God-class with unrelated responsibilities | Higher — update imports/webpack if needed | + +Each scenario must state: **files touched**, **parity checks**, **fallow metrics expected to improve**, and **review story** (what reviewer should skim first). + +### 4. Implement (small phases) + +- One logical change per phase; run tests after each phase +- After moving HTML to Django: JS only builds **context objects** and assigns `innerHTML` / `insertAdjacentHTML` from response +- When generalizing a method, keep old call sites working or update all call sites in the same phase +- Do not expand scope into unrelated templates/backend unless required for parity + +### 5. Verify + +From `gateway/`: + +```bash +npm test +npm run fallow +npx fallow health --format human +bash scripts/fallow-cross-file-dupes.sh +``` + +If behavior is UI-heavy, run through affected pages (modals, lists, upload, visualizations) per team practice. + +### 6. Deliverable for the user + +Short summary: + +- Chosen scenario and why +- Before/after notes on complexity/dupes (from fallow) +- Files changed +- Manual test checklist (bullet list) +- Any follow-ups (e.g. remove HTML fallbacks left for error paths) + +## Refactor heuristics + +- **Inline HTML in JS** → identify existing `users/components/*.html`; if none fits, propose **one** new component template (justify why partials/pages are wrong) +- **Duplicate API calls** → shared method on manager or thin wrapper in `core/` +- **Long switch on asset type** → move labels/URLs/permissions into config next to `detailsModalConfig` / `FileListConfig` patterns +- **Complex async chains** → extract steps with clear names; keep orchestration in one place +- **Fallback HTML in `catch`** → prefer a minimal error component template; avoid duplicating full happy-path markup + +## Review checklist (for PR description) + +- [ ] No new logic in Django templates except initialization +- [ ] No new files under `static/js/` root; new classes in subfolders only +- [ ] No edits under `deprecated/` +- [ ] Cross-file dupes script passes +- [ ] Jest updated or added for changed public behavior +- [ ] Parity: lists, modals, permissions, and error states manually noted + +## Additional resources + +- Fallow commands and CI hooks: [fallow-gateway.md](fallow-gateway.md) +- Project frontend rules: `.cursor/rules/django_javascript_implementation.mdc` diff --git a/.cursor/skills/gateway-static-js-refactor/fallow-gateway.md b/.cursor/skills/gateway-static-js-refactor/fallow-gateway.md new file mode 100644 index 000000000..8dc4214ce --- /dev/null +++ b/.cursor/skills/gateway-static-js-refactor/fallow-gateway.md @@ -0,0 +1,64 @@ +# Fallow (gateway) + +Run all commands from `gateway/` (config: `.fallowrc.json`). Ignores `sds_gateway/static/js/deprecated/**`. + +## npm scripts + +| Script | Purpose | +|--------|---------| +| `npm run fallow` | `fallow --summary` (overview) | +| `npm run fallow:static-js` | Dead-code per file under `static/js/` (full graph, scoped reporting) | + +## Commands to use in refactors + +```bash +# Summary / combined view +npm run fallow + +# Cyclomatic + cognitive hotspots (prioritize splits) +npx fallow health --format human +npx fallow health --format json -q # machine-readable + +# Clone groups (in-file and cross-file) +npx fallow dupes --format human +npx fallow dupes --format json -q + +# Unused exports / cycles (static/js scoped via script) +npm run fallow:static-js +npx fallow dead-code --format human + +# Changed-files gate (PR-sized work) +npx fallow audit --changed-since main --format human +``` + +## CI / pre-commit (must stay green) + +```bash +bash scripts/fallow-cross-file-dupes.sh +``` + +Fails if any clone group spans **more than one file**. Fixing usually means shared helper or single canonical implementation. + +## Interpreting results + +| Signal | Typical refactor move | +|--------|------------------------| +| High cognitive/cyclomatic on one function | Extract helpers; early return; reduce branches | +| Dupes across files | Shared util or generalize existing manager method | +| Dead export after refactor | Remove export or wire usage; re-run `fallow:static-js` | +| `audit --changed-since` only on touched files | Use while iterating; run full dupes/health before merge | + +## Config notes + +- `ignoreExportsUsedInFile: true` — same-file-only usage may not count as “used” for export pruning; prefer explicit exports only where needed. +- Do not add `deprecated/` paths to analysis scope for fixes. + +## Optional + +```bash +npx fallow config # resolved config path +npx fallow list # entry points / discovered files +npx fallow explain +``` + +For agent tooling: `npx fallow schema` (CLI JSON). 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 33067a8af..59873bc45 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 45ab09302..180a4c490 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/models.py b/gateway/sds_gateway/api_methods/models.py index f2cdd446c..608ca39c4 100644 --- a/gateway/sds_gateway/api_methods/models.py +++ b/gateway/sds_gateway/api_methods/models.py @@ -15,6 +15,7 @@ from django.db import models from django.db.models import Count from django.db.models import ProtectedError +from django.db.models import Q from django.db.models import QuerySet from django.db.models import Sum from django.db.models.signals import post_save @@ -498,6 +499,66 @@ def get_drf_data_files_stats(self) -> dict[str, int]: } return self._drf_data_files_stats_cache + def get_capture_files_stats(self) -> dict[str, int]: + """ + Count + total size for all files linked to this capture (any type). + + Cached per instance. + """ + if hasattr(self, "_capture_files_stats_cache"): + return self._capture_files_stats_cache + + from sds_gateway.api_methods.utils.relationship_utils import ( # noqa: PLC0415 + get_capture_files, + ) + + qs = get_capture_files(self, include_deleted=False) + agg = qs.aggregate(total_count=Count("pk"), total_size=Sum("size")) + self._capture_files_stats_cache = { + "total_count": agg["total_count"] or 0, + "total_size": int(agg["total_size"] or 0), + } + return self._capture_files_stats_cache + + def get_files_summary(self) -> dict[str, Any]: + """ + Unified file summary for API/UI (all capture types). + + Cached per instance. DRF captures include a ``data_files`` subset. + """ + if hasattr(self, "_files_summary_cache"): + return self._files_summary_cache + + all_stats = self.get_capture_files_stats() + summary: dict[str, Any] = { + "total_count": all_stats["total_count"], + "total_size": all_stats["total_size"], + } + + if self.capture_type == CaptureType.DigitalRF: + drf = self.get_drf_data_files_stats() + count = drf["total_count"] + size = drf["total_size"] + summary["data_files"] = { + "count": count, + "total_size": size, + "per_data_file_size": (float(size) / count) if count else None, + } + if summary["total_size"] < size: + log.warning( + ( + "Capture %s: total_size (%s) < data_files total_size (%s); " + "using data total." + ), + str(self.uuid), + summary["total_size"], + size, + ) + summary["total_size"] = size + + self._files_summary_cache = summary + return self._files_summary_cache + def get_opensearch_metadata(self) -> dict[str, Any]: """ Query OpenSearch for frequency metadata for this specific capture. @@ -912,6 +973,53 @@ def from_db(cls, db, field_names, values): setattr(instance, field, json.loads(getattr(instance, field))) return instance + def get_dataset_file_statistics(self) -> dict[str, int]: + """ + Aggregate file counts/sizes for this dataset (artifacts + capture files). + + Cached per instance. One queryset pass for totals; separate filters for + capture-linked vs artifact-only files. + """ + if hasattr(self, "_dataset_file_statistics_cache"): + return self._dataset_file_statistics_cache + + from sds_gateway.api_methods.utils.relationship_utils import ( # noqa: PLC0415 + get_dataset_files_including_captures, + ) + + files_qs = get_dataset_files_including_captures(self, include_deleted=False) + total_files = files_qs.count() + total_size = int( + files_qs.aggregate(total=Sum("size"))["total"] or 0, + ) + captures_count = ( + files_qs.filter(Q(capture__isnull=False) | Q(captures__isnull=False)) + .distinct() + .count() + ) + artifacts_count = files_qs.filter( + capture__isnull=True, + captures__isnull=True, + ).count() + + self._dataset_file_statistics_cache = { + "total_files": total_files, + "captures": captures_count, + "artifacts": artifacts_count, + "total_size": total_size, + } + return self._dataset_file_statistics_cache + + def get_files_summary(self) -> dict[str, Any]: + """Alias for dataset file statistics in API serializers.""" + stats = self.get_dataset_file_statistics() + return { + "total_count": stats["total_files"], + "total_size": stats["total_size"], + "capture_linked_files": stats["captures"], + "artifact_files": stats["artifacts"], + } + def get_authors_display(self): """Get the authors as a list for display purposes.""" if not self.authors: diff --git a/gateway/sds_gateway/api_methods/serializers/capture_serializers.py b/gateway/sds_gateway/api_methods/serializers/capture_serializers.py index ff0e9129e..d3f3dd8ff 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,8 +116,10 @@ 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_count = serializers.SerializerMethodField() total_file_size = serializers.SerializerMethodField() data_files_info = serializers.SerializerMethodField() datasets = serializers.SerializerMethodField() @@ -136,7 +139,7 @@ class CaptureGetSerializer(serializers.ModelSerializer[Capture]): def get_datasets(self, capture: Capture) -> list[dict[str, Any]]: """Datasets linked to this capture (summary rows only; avoids nested graphs).""" qs = get_capture_datasets(capture, include_deleted=False) - return DatasetSummarySerializer(qs, many=True, context=self.context).data + return DatasetSummarySerializer(qs, many=True, context=self.context or {}).data def get_share_permissions(self, capture: Capture) -> list[UserSharePermission]: """Get the share permissions for the capture.""" @@ -158,7 +161,7 @@ def get_is_shared_with_me(self, capture: Capture) -> bool: item_uuid=capture.uuid, is_enabled=True, is_deleted=False, - ).exists() + ).exclude(owner=request.user).exists() return False def get_is_shared(self, capture: Capture) -> bool: @@ -169,56 +172,68 @@ 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 or {}).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. Returns: A list of serialized file objects with uuid, name, and directory fields. """ + exclude_files = (self.context or {}).get("exclude_files", False) + if exclude_files: + return [] + non_deleted_files = get_capture_files(capture, include_deleted=False) return FileSummarySerializer( - non_deleted_files, many=True, context=self.context + non_deleted_files, many=True, context=self.context or {} ).data @extend_schema_field(serializers.IntegerField(allow_null=True)) - def get_total_file_size(self, capture: Capture) -> int | None: - """Get the total file size of all files associated with this capture.""" - - if capture.capture_type != CaptureType.DigitalRF: - return None - - all_files = get_capture_files(capture, include_deleted=False) - result = all_files.aggregate(total_size=Sum("size")) - total = result["total_size"] or 0 - data_total = self.get_data_files_info(capture).get("total_size", 0) - if total < data_total: - logging.getLogger(__name__).warning( - ( - "Capture %s: total_file_size (%s) < data_files_total_size (%s); " - "using data total." - ), - str(capture.uuid), - total, - data_total, - ) - total = data_total + def get_total_file_count(self, capture: Capture) -> int: + """Get the total file count for the capture.""" + return capture.get_files_summary()["total_count"] - return total + @extend_schema_field(serializers.IntegerField(allow_null=True)) + def get_total_file_size(self, capture: Capture) -> int: + """Get the total file size of all files associated with this capture.""" + return capture.get_files_summary()["total_size"] @extend_schema_field(serializers.DictField(allow_null=True)) def get_data_files_info(self, capture: Capture) -> dict[str, Any]: - """Get the data files info for the capture.""" - if capture.capture_type != CaptureType.DigitalRF: - return {} - - stats = capture.get_drf_data_files_stats() - total_size = stats["total_size"] - count = stats["total_count"] - return { - "count": count, - "total_size": total_size, - "per_data_file_size": (float(total_size) / count) if count else None, + """Get the file summary for the capture (all types; DRF includes data_files).""" + summary = capture.get_files_summary() + data_files = summary.get("data_files") + result: dict[str, Any] = { + "total_count": summary["total_count"], + "total_size": summary["total_size"], } + if data_files: + result["count"] = data_files["count"] + result["data_files_total_size"] = data_files["total_size"] + result["per_data_file_size"] = data_files.get("per_data_file_size") + else: + result["count"] = summary["total_count"] + return result @extend_schema_field(serializers.FloatField) def get_center_frequency_ghz(self, capture: Capture) -> float | None: @@ -487,6 +502,7 @@ class CompositeCaptureSerializer(serializers.Serializer): # Computed fields share_permissions = serializers.SerializerMethodField() files = serializers.SerializerMethodField() + total_file_count = serializers.SerializerMethodField() total_file_size = serializers.SerializerMethodField() data_files_info = serializers.SerializerMethodField() formatted_created_at = serializers.SerializerMethodField() @@ -619,7 +635,12 @@ def get_is_shared(self, obj: dict[str, Any]) -> bool: def get_files(self, obj: dict[str, Any]) -> ReturnList[File]: """Get all files from all channels in the composite capture.""" - all_files = [] + all_files: list[File] = [] + + exclude_files = (self.context or {}).get("exclude_files", False) + if exclude_files: + return all_files + for channel_data in obj.get("channels") or []: capture = self._capture_for_channel(obj, channel_data) if capture is None: @@ -628,61 +649,61 @@ def get_files(self, obj: dict[str, Any]) -> ReturnList[File]: serializer = FileSummarySerializer( non_deleted_files, many=True, - context=self.context, + context=self.context or {}, ) all_files.extend(serializer.data) return cast("ReturnList[File]", all_files) - def get_total_file_size(self, obj: dict[str, Any]) -> int | None: - """Get the total file size across all channels.""" - if obj["capture_type"] != CaptureType.DigitalRF: - return None + @extend_schema_field(serializers.IntegerField()) + def get_total_file_count(self, obj: dict[str, Any]) -> int: + """Total file count across all channels.""" + total = 0 + for channel_data in obj.get("channels") or []: + capture = self._capture_for_channel(obj, channel_data) + if capture is None: + continue + total += capture.get_files_summary()["total_count"] + return total + @extend_schema_field(serializers.IntegerField()) + def get_total_file_size(self, obj: dict[str, Any]) -> int: + """Total file size across all channels.""" total_size = 0 for channel_data in obj.get("channels") or []: capture = self._capture_for_channel(obj, channel_data) if capture is None: continue - all_files = get_capture_files(capture, include_deleted=False) - result = all_files.aggregate(total_size=Sum("size")) - total_size += result["total_size"] or 0 - - data_total = self.get_data_files_info(obj).get("total_size", 0) - - if total_size < data_total: - logging.getLogger(__name__).warning( - ( - "Composite capture: total_file_size (%s) < " - "data_files_total_size (%s); using data total." - ), - total_size, - data_total, - ) - total_size = data_total + total_size += capture.get_files_summary()["total_size"] return total_size def get_data_files_info(self, obj: dict[str, Any]) -> dict[str, Any]: - """Get the data files info for the composite capture.""" - if obj["capture_type"] != CaptureType.DigitalRF: - return {} - + """File summary aggregated across composite channels.""" total_count = 0 total_size = 0 + drf_count = 0 + drf_size = 0 for channel_data in obj.get("channels") or []: capture = self._capture_for_channel(obj, channel_data) if capture is None: continue - stats = capture.get_drf_data_files_stats() - total_count += stats["total_count"] - total_size += stats["total_size"] - - return { - "count": total_count, + summary = capture.get_files_summary() + total_count += summary["total_count"] + total_size += summary["total_size"] + data_files = summary.get("data_files") + if data_files: + drf_count += data_files["count"] + drf_size += data_files["total_size"] + + result: dict[str, Any] = { + "count": drf_count if drf_count else total_count, "total_size": total_size, - "per_data_file_size": (float(total_size) / total_count) - if total_count - else None, + "total_count": total_count, } + if drf_count: + result["per_data_file_size"] = ( + float(drf_size) / drf_count if drf_count else None + ) + return result @extend_schema_field(serializers.CharField) def get_formatted_created_at(self, obj: dict[str, Any]) -> str: diff --git a/gateway/sds_gateway/api_methods/serializers/dataset_serializers.py b/gateway/sds_gateway/api_methods/serializers/dataset_serializers.py index 72dd9b657..d99eb826e 100644 --- a/gateway/sds_gateway/api_methods/serializers/dataset_serializers.py +++ b/gateway/sds_gateway/api_methods/serializers/dataset_serializers.py @@ -1,5 +1,7 @@ """Dataset serializers for the API methods.""" +from typing import Any + from rest_framework import serializers from sds_gateway.api_methods.models import Dataset @@ -37,6 +39,7 @@ class DatasetGetSerializer(serializers.ModelSerializer[Dataset]): share_permissions = serializers.SerializerMethodField() captures = serializers.SerializerMethodField() files = serializers.SerializerMethodField() + data_files_info = serializers.SerializerMethodField() owner_name = serializers.SerializerMethodField() owner_email = serializers.SerializerMethodField() permission_level = serializers.SerializerMethodField() @@ -62,7 +65,7 @@ def get_is_shared_with_me(self, obj): item_uuid=obj.uuid, is_enabled=True, is_deleted=False, - ).exists() + ).exclude(owner=request.user).exists() return False def get_is_owner(self, obj): @@ -148,6 +151,10 @@ def get_files(self, obj: Dataset) -> list[dict]: Returns: A list of serialized file objects """ + exclude_files = (self.context or {}).get("exclude_files", False) + if exclude_files: + return [] + non_deleted_files = get_dataset_artifact_files( obj, include_deleted=False, @@ -155,17 +162,21 @@ def get_files(self, obj: Dataset) -> list[dict]: serializer = FileSummarySerializer( non_deleted_files, many=True, - context=self.context, + context=self.context or {}, ) return serializer.data + def get_data_files_info(self, obj: Dataset) -> dict[str, Any]: + """File summary for the dataset (counts and sizes, no file listing).""" + return obj.get_files_summary() + def get_captures(self, obj: Dataset) -> list[dict]: """Captures for the dataset (one summary row per logical capture). Multi-channel uploads share ``top_level_dir`` and are merged into one summary row (see :func:`summary_serializers.composite_capture_summary`). """ - return serialize_captures_for_detail(obj, context=self.context) + return serialize_captures_for_detail(obj, context=self.context or {}) def get_is_shared(self, obj): """Check if the dataset is shared.""" @@ -319,7 +330,12 @@ class Meta: ] -def get_dataset_serializer(dataset: Dataset, *, has_user_access: bool) -> dict: # pyright: ignore[reportMissingTypeArgument] +def get_dataset_serializer( + dataset: Dataset, + *, + has_user_access: bool, + context: dict[str, Any] | None = None, +) -> dict: # pyright: ignore[reportMissingTypeArgument] """ Get serialized dataset data using the appropriate serializer. @@ -337,5 +353,5 @@ def get_dataset_serializer(dataset: Dataset, *, has_user_access: bool) -> dict: (excludes sensitive fields like shared_with user IDs) """ if has_user_access: - return DatasetGetSerializer(dataset).data + return DatasetGetSerializer(dataset, context=context or {}).data return DatasetPublicSerializer(dataset).data diff --git a/gateway/sds_gateway/static/css/components.css b/gateway/sds_gateway/static/css/components.css index 13f53494d..d9510c9fa 100644 --- a/gateway/sds_gateway/static/css/components.css +++ b/gateway/sds_gateway/static/css/components.css @@ -1345,6 +1345,11 @@ body { color: var(--red-text); } +/* Share groups list: action dropdowns are not clipped by table scroll */ +.share-groups-table-responsive.table-responsive { + overflow: visible; +} + /* Remove member button sizing for share groups */ .remove-member-btn.btn-danger, .remove-member-btn.btn-outline-danger { 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 ` - - - - ${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 %} @@ -188,10 +192,10 @@
{{ notification.user_message }}
{% endfor %} {% endif %} - {% comment %} Django messages rendered as toasts {% endcomment %} + {% comment %} Django messages: server-rendered toasts via message.html {% endcomment %} {% if messages %} {% for message in messages %} - {% include "users/components/toast.html" with message=message type=message.level_tag|default:"info" %} + {% include "users/components/message.html" with message=message type=message.level_tag|default:"info" presentation="toast" %} {% endfor %} {% endif %} {% block body %} diff --git a/gateway/sds_gateway/templates/pages/home.html b/gateway/sds_gateway/templates/pages/home.html index c230b392a..8c552f007 100644 --- a/gateway/sds_gateway/templates/pages/home.html +++ b/gateway/sds_gateway/templates/pages/home.html @@ -59,10 +59,11 @@

Latest Public Datasets

{{ dataset.name }} + class="dataset-name-link text-decoration-none dataset-details-open" + data-dataset-uuid="{{ dataset.uuid }}" + data-item-uuid="{{ dataset.uuid }}" + data-item-type="dataset" + aria-label="View details for {{ dataset.name }}">{{ dataset.name }} {% if dataset.is_public %}{% endif %}
@@ -163,13 +164,11 @@

SpectrumX Website

- - {% for dataset in latest_datasets %} - {% include "users/partials/dataset_details_modal.html" with dataset=dataset %} - {% endfor %} + {% include "users/partials/asset_details_modal.html" %} {% endblock body %} {% block javascript %} {{ block.super }} + @@ -177,28 +176,7 @@

SpectrumX Website

{# djlint:off #} {# djlint:on #} diff --git a/gateway/sds_gateway/templates/users/capture_list.html b/gateway/sds_gateway/templates/users/capture_list.html new file mode 100644 index 000000000..edb8a466a --- /dev/null +++ b/gateway/sds_gateway/templates/users/capture_list.html @@ -0,0 +1,442 @@ +{# Captures list page (shell + filters + table + modals). Legacy URL/template name was file_list.html. #} +{% extends "base.html" %} + +{% load static %} + +{% block bodyclass %} + hero-white-page +{% endblock bodyclass %} +{% block css %} + {{ block.super }} + + +{% endblock css %} +{% block body %} + + + + + + + + + + + {% csrf_token %} + + {% if system_notifications %} +
+ {% for notification in system_notifications %} +
{{ notification.user_message }}
+ {% endfor %} +
+ {% endif %} + {% if messages %} +
+ {% for message in messages %} + {% include "users/components/message.html" with message=message type=message.level_tag|default:"info" presentation="toast" %} + {% endfor %} +
+ {% endif %} +
+
+
+
+ +
+
+

Captures

+
+
+ +
+ + + +
+ + + +
+
+ Loading... +
+

Searching captures...

+
+ +
+ 0 captures selected + +
+ +
+ {% include "users/components/asset_list_table.html" with page_obj=page_obj asset_type=asset_type table_headers=table_headers no_assets_message=no_assets_message asset_row_template=asset_row_template ajax_fragment=ajax_fragment %} +
+
+
+
+
+
+ + {% include "users/partials/quick_add_to_dataset_modal.html" %} + {% include "users/partials/asset_details_modal.html" %} + + {% include "users/partials/upload_capture_modal.html" %} + + {% include "users/partials/upload_result_modal.html" %} +
+ + {% include "users/components/capture_list_modals.html" with captures=page_obj.object_list %} +
+{% endblock body %} +{% block javascript %} + {{ block.super }} + + + + + + + + + + + + + + + + {# djlint:off #} + + {# djlint:on #} + + + + + + + + {# djlint:off #} + + + {# djlint:on #} +{% endblock javascript %} diff --git a/gateway/sds_gateway/templates/users/components/asset_list_table.html b/gateway/sds_gateway/templates/users/components/asset_list_table.html new file mode 100644 index 000000000..445870574 --- /dev/null +++ b/gateway/sds_gateway/templates/users/components/asset_list_table.html @@ -0,0 +1,104 @@ +{% comment %} +Single source for the dataset list table (full page + AJAX refresh). + +Context: +- page_obj, sort_by, sort_order (required) +- asset_type: "dataset" | "capture" | "file" (default: "asset") +- table_headers: list of { label, col_class, aria_label?, text_align? } +- table_class: extra class on (e.g. capture-list-table) +- no_assets_message: message to display if no assets are found +- asset_row_template: template to render for each asset row +- ajax_fragment: True for AJAX partial (omit outer #dataset-list-ajax-wrapper) +{% endcomment %} +{% load tz %} + +{# Wrapper for ListRefreshManager; omitted when injecting into existing wrapper #} +{% if not ajax_fragment %}
{% endif %} + + {% if page_obj.object_list %} +
+ + {{ page_obj.paginator.count }} {{ asset_type|default:"asset" }}{{ page_obj.paginator.count|pluralize }} found + +
+ {% endif %} + +
+
+
+ {% if not page_obj.object_list %} +
+ + {% include "users/components/asset_list_table_thead.html" %} +
Your {{ asset_type|default:"asset" }} list
+ + {% else %} + + + {% include "users/components/asset_list_table_thead.html" %} + + {% for asset in page_obj %} + {% if "dataset_list_table_row" in asset_row_template %} + {% include asset_row_template with dataset=asset dropdown_menu_items=asset.dropdown_menu_items %} + {% else %} + {% include asset_row_template with capture=asset dropdown_menu_items=asset.dropdown_menu_items %} + {% endif %} + {% endfor %} + +
Your {{ asset_type|default:"asset" }} list
+ {% endif %} + + + + {% if page_obj and page_obj.has_other_pages %} + + {% endif %} + {% if not ajax_fragment %}{% endif %} diff --git a/gateway/sds_gateway/templates/users/components/asset_list_table_thead.html b/gateway/sds_gateway/templates/users/components/asset_list_table_thead.html new file mode 100644 index 000000000..634977335 --- /dev/null +++ b/gateway/sds_gateway/templates/users/components/asset_list_table_thead.html @@ -0,0 +1,12 @@ +{% comment %} +table_headers: list of { label, col_class, aria_label?, text_align? } +{% endcomment %} + + + {% for header in table_headers %} + {{ header.label }} + {% endfor %} + + diff --git a/gateway/sds_gateway/templates/users/components/badge.html b/gateway/sds_gateway/templates/users/components/badge.html deleted file mode 100644 index 35ba47896..000000000 --- a/gateway/sds_gateway/templates/users/components/badge.html +++ /dev/null @@ -1,5 +0,0 @@ -{% comment %} -Badge Component -Renders Bootstrap badge -{% endcomment %} -{{ text }} diff --git a/gateway/sds_gateway/templates/users/components/button.html b/gateway/sds_gateway/templates/users/components/button.html deleted file mode 100644 index 719d30baa..000000000 --- a/gateway/sds_gateway/templates/users/components/button.html +++ /dev/null @@ -1,16 +0,0 @@ -{% comment %} -Button Component -Renders Bootstrap button with optional icon and loading state -{% endcomment %} - diff --git a/gateway/sds_gateway/templates/users/components/capture_details_modal_body.html b/gateway/sds_gateway/templates/users/components/capture_details_modal_body.html new file mode 100644 index 000000000..cbf19bf8b --- /dev/null +++ b/gateway/sds_gateway/templates/users/components/capture_details_modal_body.html @@ -0,0 +1,164 @@ +{% comment %} +Capture details modal body — rendered server-side for ModalManager injection. +{% endcomment %} +
+
+
+ Basic Information +
+
+
+ +
+ + + + +
+
Click the edit button to modify the capture name
+
+
+
+

+ Capture Type: + {{ capture.capture_type_display|default:capture.capture_type|default:"N/A" }} +

+

+ Origin: + {{ capture.origin|default:"N/A" }} +

+
+
+

+ Owner: + {{ owner_display }} +

+
+
+
+ {{ channel_label }}: + {{ channel_value }} +
+
+
+
+
+ Technical Details +
+
+
+
+

+ 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 }} +

+
+
+
+
+
+
+ Timestamps +
+
+
+
+

+ Created At: +
+ {{ capture.formatted_created_at|default:capture.created_at }} +

+
+
+

+ Updated At: +
+ {{ capture.updated_at }} +

+
+
+
+{% if accordion_channels %} +
+
Channel Details
+
+ {% for ch in accordion_channels %} +
+

+ +

+
+
+
+ {% if ch.metadata_rows %} + {% for row in ch.metadata_rows %} +

+ {{ row.label }}: {{ row.value }} +

+ {% endfor %} + {% else %} + No metadata available + {% endif %} +
+
+
+
+ {% endfor %} +
+
+{% endif %} +
+ {% include "users/components/capture_files_summary_fragment.html" %} +
diff --git a/gateway/sds_gateway/templates/users/components/capture_files_summary_fragment.html b/gateway/sds_gateway/templates/users/components/capture_files_summary_fragment.html new file mode 100644 index 000000000..02fb87ca4 --- /dev/null +++ b/gateway/sds_gateway/templates/users/components/capture_files_summary_fragment.html @@ -0,0 +1,20 @@ +{% load humanize %} +
+
+
+ Files Summary +
+
+
+

+ Number of Files: + {{ files_count }} +

+
+
+

+ Total Size: + {{ total_size|filesizeformat }} +

+
+
diff --git a/gateway/sds_gateway/templates/users/components/capture_list_modals.html b/gateway/sds_gateway/templates/users/components/capture_list_modals.html new file mode 100644 index 000000000..9252d19a5 --- /dev/null +++ b/gateway/sds_gateway/templates/users/components/capture_list_modals.html @@ -0,0 +1,9 @@ +{% comment %} +Per-capture modals: share + web download (full page + AJAX list refresh). +{% endcomment %} +{% for cap in captures %} + {% if cap.capture %} + {% include "users/partials/share_modal.html" with item=cap item_type="capture" %} + {% include "users/partials/web_download_modal.html" with item=cap item_type="capture" %} + {% endif %} +{% endfor %} diff --git a/gateway/sds_gateway/templates/users/components/capture_list_table_row.html b/gateway/sds_gateway/templates/users/components/capture_list_table_row.html new file mode 100644 index 000000000..cbeb7b3d7 --- /dev/null +++ b/gateway/sds_gateway/templates/users/components/capture_list_table_row.html @@ -0,0 +1,58 @@ +{% comment %} +Single source for the capture list table (full page + AJAX refresh). + +Context: +- capture: capture object +- dropdown_menu_items: list of dropdown menu items +{% endcomment %} +{% load tz %} + + + + {% if capture.is_owner %} + + {% else %} + + {% endif %} + + + + {{ capture.name|default:"Unnamed Capture" }} + + {% if capture.is_shared_with_me %} + + + + {% endif %} + + {{ capture.top_level_dir|default:"-" }} + {{ capture.capture_type_display }} + + {% if capture.capture.created_at %} + {% localtime on %} +
+ {{ capture.capture.created_at|date:"Y-m-d" }} + {{ capture.capture.created_at|date:"H:i:s T" }} +
+ {% endlocaltime %} + {% else %} + - + {% endif %} + + + {% include "users/components/dropdown_menu.html" with items=dropdown_menu_items %} + + diff --git a/gateway/sds_gateway/templates/users/components/dataset_details_modal_body.html b/gateway/sds_gateway/templates/users/components/dataset_details_modal_body.html new file mode 100644 index 000000000..8c7537f2c --- /dev/null +++ b/gateway/sds_gateway/templates/users/components/dataset_details_modal_body.html @@ -0,0 +1,147 @@ +{% load tz %} +
+
+
+
+
+
+ Dataset Information +
+
+
+
+
+
+ +
+

{{ dataset.name }} (v{{ dataset.version }})

+ +
+
+
+ +

+ {% if dataset.authors %} + {% for author in dataset.authors %} + {% firstof author.name author %} + {% if not forloop.last %},{% endif %} + {% endfor %} + {% else %} + No authors specified + {% endif %} +

+
+
+
+
+ +

+ {% 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" }}

+
+
+ +
+ {% if dataset.keywords %} + {% for keyword in dataset.keywords %} + {{ keyword }} + {% endfor %} + {% else %} + No keywords + {% endif %} +
+
+
+
+
+
+
+
+
+
+
+ File Summary +
+
+
+
+
+
+
+
Total Files
+

{{ statistics.total_files }}

+
+
+
+
+
+
+
Capture Files
+

{{ statistics.captures }}

+
+
+
+
+
+
+
Artifact Files
+

{{ statistics.artifacts }}

+
+
+
+
+
+
+
Total Size
+

{{ statistics.total_size|filesizeformat }}

+
+
+
+
+
+
+
+
+
diff --git a/gateway/sds_gateway/templates/users/components/dataset_list_modals.html b/gateway/sds_gateway/templates/users/components/dataset_list_modals.html index d9475ea8f..c9223469e 100644 --- a/gateway/sds_gateway/templates/users/components/dataset_list_modals.html +++ b/gateway/sds_gateway/templates/users/components/dataset_list_modals.html @@ -4,10 +4,11 @@ Context: page_obj (paginated dataset list) {% endcomment %} {% for dataset in page_obj %} - {% include "users/partials/dataset_details_modal.html" with dataset=dataset %} {% include "users/partials/web_download_modal.html" with item=dataset item_type="dataset" %} - {% include "users/partials/share_modal.html" with item=dataset item_type="dataset" %} {% include "users/partials/sdk_download_modal.html" with dataset=dataset %} + {% if dataset.is_owner or dataset.permission_level == 'co-owner' or dataset.permission_level == 'contributor' %} + {% include "users/partials/share_modal.html" with item=dataset item_type="dataset" %} + {% endif %} {% if dataset.is_owner or dataset.permission_level == 'co-owner' %} {% include "users/partials/dataset_version_control.html" with dataset=dataset %} {% if not dataset.dataset.status == 'final' or not dataset.is_public %} diff --git a/gateway/sds_gateway/templates/users/components/dataset_list_table.html b/gateway/sds_gateway/templates/users/components/dataset_list_table.html deleted file mode 100644 index 2257a7244..000000000 --- a/gateway/sds_gateway/templates/users/components/dataset_list_table.html +++ /dev/null @@ -1,257 +0,0 @@ -{% comment %} -Single source for the dataset list table (full page + AJAX refresh). - -Context: -- page_obj, sort_by, sort_order (required) -- ajax_fragment: True for AJAX partial (omit outer #dataset-list-ajax-wrapper) -{% endcomment %} -{% load tz %} - -{# Wrapper for ListRefreshManager; omitted when injecting into existing wrapper #} -{% if not ajax_fragment %}
{% endif %} - - {% if page_obj.object_list %} -
- - {{ page_obj.paginator.count }} dataset{{ page_obj.paginator.count|pluralize }} found - -
- {% endif %} - -
-
-
- {% if not page_obj %} - - - - - - - - - - -
Your datasets list
- Dataset Name - - Author - - Created At - Actions
- - {% else %} - - - - - - - - - - - - {% for dataset in page_obj %} - - - - - - - {% endfor %} - -
Your datasets list
- 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 %} -
- -
- {% endif %} -
-
-
-{% if page_obj and page_obj.has_other_pages %} - -{% endif %} -{% if not ajax_fragment %}
{% endif %} diff --git a/gateway/sds_gateway/templates/users/components/dataset_list_table_row.html b/gateway/sds_gateway/templates/users/components/dataset_list_table_row.html new file mode 100644 index 000000000..33cb6aae0 --- /dev/null +++ b/gateway/sds_gateway/templates/users/components/dataset_list_table_row.html @@ -0,0 +1,82 @@ +{% comment %} +Single source for the dataset list table (full page + AJAX refresh). + +Context: +- dataset: dataset object +- dropdown_menu_items: list of dropdown menu items +{% endcomment %} +{% load tz %} + + + + {% if dataset.is_owner %} + {{ dataset.name }} (v{{ dataset.version }}) + {% else %} + {{ dataset.name }} (v{{ dataset.version }}) + {% 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 %} + +{% include "users/components/dropdown_menu.html" with items=dropdown_menu_items %} + diff --git a/gateway/sds_gateway/templates/users/components/dropdown_menu.html b/gateway/sds_gateway/templates/users/components/dropdown_menu.html index 569b1bdb4..35671035c 100644 --- a/gateway/sds_gateway/templates/users/components/dropdown_menu.html +++ b/gateway/sds_gateway/templates/users/components/dropdown_menu.html @@ -29,7 +29,6 @@ + + + {% endwith %} +{% elif presentation == "table" %} {% if message %} @@ -22,9 +31,8 @@ {% endif %} -{% elif format == 'alert' %} - {# Alert box error format #} - -{% elif format == 'list' %} - {# List error format #} +{% elif presentation == "visualization_panel" %} +
+

{{ message }}

+ {% if detail_line %} +

{{ detail_line }}

+ {% else %} +

+ {% endif %} +
+{% elif presentation == "list" %} {% else %} - {# Inline error format (default) #} {{ message|default:"Error loading data" }} {% endif %} diff --git a/gateway/sds_gateway/templates/users/components/modal_file_tree.html b/gateway/sds_gateway/templates/users/components/modal_file_tree.html deleted file mode 100644 index 311fb4287..000000000 --- a/gateway/sds_gateway/templates/users/components/modal_file_tree.html +++ /dev/null @@ -1,38 +0,0 @@ -{% comment %} -Modal File Tree Component -Renders file tree for dataset details modal - -Context: -{ - "rows": [ - { - "indent_level": 0, - "icon": "bi-file-earmark", - "icon_color": "text-primary", - "name": "file.txt", - "type": "File", - "size": "1.2 MB", - "created_at": "Jan 1, 2024", - "has_chevron": false - } - ] -} -{% endcomment %} -{% for row in rows %} - - - {% if row.has_chevron %}{% endif %} - - - {% for i in row.indent_range %}    {% endfor %} - {{ row.name }} - - {{ row.type }} - {{ row.size }} - {{ row.created_at }} - -{% empty %} - - No files found - -{% endfor %} diff --git a/gateway/sds_gateway/templates/users/components/modal_permissions.html b/gateway/sds_gateway/templates/users/components/modal_permissions.html deleted file mode 100644 index ae97c7cd8..000000000 --- a/gateway/sds_gateway/templates/users/components/modal_permissions.html +++ /dev/null @@ -1,18 +0,0 @@ -{% comment %} -Modal Permissions Component -Renders permissions list for dataset details modal -{% endcomment %} -{% for permission in permissions %} -
- - {% if permission.user_name %} - {{ permission.user_name }} ({{ permission.user_email }}) - {% else %} - {{ permission.user_email }} - {% endif %} - - {{ permission.display_name }} -
-{% empty %} - No shared permissions -{% endfor %} diff --git a/gateway/sds_gateway/templates/users/components/modal_technical_details.html b/gateway/sds_gateway/templates/users/components/modal_technical_details.html deleted file mode 100644 index 6df03d6c1..000000000 --- a/gateway/sds_gateway/templates/users/components/modal_technical_details.html +++ /dev/null @@ -1,11 +0,0 @@ -{% comment %} -Modal Technical Details Component -Renders technical details for capture details modal -{% endcomment %} -{% if details %} - -{% else %} - No technical details available -{% endif %} diff --git a/gateway/sds_gateway/templates/users/components/share_group_table_row.html b/gateway/sds_gateway/templates/users/components/share_group_table_row.html index e7fa6b29b..10f1a27e8 100644 --- a/gateway/sds_gateway/templates/users/components/share_group_table_row.html +++ b/gateway/sds_gateway/templates/users/components/share_group_table_row.html @@ -29,7 +29,6 @@ - - -{% endwith %} diff --git a/gateway/sds_gateway/templates/users/dataset_list.html b/gateway/sds_gateway/templates/users/dataset_list.html index 4ead29d05..3a729ec89 100644 --- a/gateway/sds_gateway/templates/users/dataset_list.html +++ b/gateway/sds_gateway/templates/users/dataset_list.html @@ -26,56 +26,43 @@

Datasets


- {% include "users/components/dataset_list_table.html" %} + {% include "users/components/asset_list_table.html" with page_obj=page_obj asset_type=asset_type table_headers=table_headers no_assets_message=no_assets_message asset_row_template=asset_row_template ajax_fragment=ajax_fragment %} - - {% include "users/components/dataset_list_modals.html" with page_obj=page_obj %} + + {% include "users/partials/asset_details_modal.html" %} +
+ {% include "users/components/dataset_list_modals.html" with page_obj=page_obj %} +
{% endblock content %} {% block javascript %} {# djlint:off #} {{ block.super }} - - + + + + + + + + + {# djlint:on #} diff --git a/gateway/sds_gateway/templates/users/file_list.html b/gateway/sds_gateway/templates/users/file_list.html deleted file mode 100644 index bfd9e9ccb..000000000 --- a/gateway/sds_gateway/templates/users/file_list.html +++ /dev/null @@ -1,533 +0,0 @@ -{# templates/users/file_list.html #} -{% extends "base.html" %} - -{% load static %} - -{% block bodyclass %} - hero-white-page -{% endblock bodyclass %} -{% block css %} - {{ block.super }} - - -{% endblock css %} -{% block body %} - - - - - - - - - - - {% csrf_token %} - - {% if system_notifications %} -
- {% for notification in system_notifications %} -
{{ notification.user_message }}
- {% endfor %} -
- {% endif %} - {% if messages %} -
- {% for message in messages %} - {% if message.tags == "error" %} - {% include "users/components/toast.html" with message=message type="error" %} - {% elif message.tags == "success" %} - {% include "users/components/toast.html" with message=message type="success" %} - {% elif message.tags == "warning" %} - {% include "users/components/toast.html" with message=message type="warning" %} - {% else %} - {% include "users/components/toast.html" with message=message type="info" %} - {% endif %} - {% endfor %} -
- {% endif %} -
-
- -
-
-

Captures

-
-
- -
- - - -
- - - -
-
- Loading... -
-

Searching captures...

-
- - - - - - {% include "users/partials/captures_page_table.html" %} -
-
-
- - {% include "users/partials/capture_modal.html" %} - - {% include "users/partials/quick_add_to_dataset_modal.html" %} - - {% 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 %} -{% endblock body %} -{% block javascript %} - {{ block.super }} - - - - - - - - - - - - - {# djlint:off #} - - - {# djlint:on #} -{% endblock javascript %} diff --git a/gateway/sds_gateway/templates/users/files.html b/gateway/sds_gateway/templates/users/files.html index 44028e2e8..727484c0f 100644 --- a/gateway/sds_gateway/templates/users/files.html +++ b/gateway/sds_gateway/templates/users/files.html @@ -139,7 +139,6 @@

Files

- - - - - - - - - + {% include "users/partials/upload_capture_modal.html" with show_upload_zone=True %} + {% include "users/partials/upload_result_modal.html" %} - - {% include "users/partials/capture_modal.html" %} + {% include "users/partials/asset_details_modal.html" %} {% for item in items %} {% if item.is_capture %} @@ -511,12 +369,20 @@ {% endfor %} {% endblock content %} {% block javascript %} - - - {# Fallback modal helpers provided by components.js; inline duplicates removed #} - - + {{ block.super }} + + + + + + + + + + + + @@ -526,6 +392,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..741f450f7 100644 --- a/gateway/sds_gateway/templates/users/group_captures.html +++ b/gateway/sds_gateway/templates/users/group_captures.html @@ -114,7 +114,11 @@
{% block extra_js %} + + + + diff --git a/gateway/sds_gateway/templates/users/partials/capture_modal.html b/gateway/sds_gateway/templates/users/partials/asset_details_modal.html similarity index 66% rename from gateway/sds_gateway/templates/users/partials/capture_modal.html rename to gateway/sds_gateway/templates/users/partials/asset_details_modal.html index 29366433b..a53fc82d8 100644 --- a/gateway/sds_gateway/templates/users/partials/capture_modal.html +++ b/gateway/sds_gateway/templates/users/partials/asset_details_modal.html @@ -1,22 +1,19 @@ - +{# Shared shell for capture/dataset details (body from /users/details-modal///). #} - - -{% if page_obj %} - {% for dataset in page_obj %} - {% include "users/partials/dataset_details_modal.html" with dataset=dataset %} - {% endfor %} -{% endif %} -{% block javascript %} - {{ block.super }} - - -{% endblock javascript %} -{% endblock content %} diff --git a/gateway/sds_gateway/templates/users/share_group_list.html b/gateway/sds_gateway/templates/users/share_group_list.html index b8a2451d0..fc25de8fc 100644 --- a/gateway/sds_gateway/templates/users/share_group_list.html +++ b/gateway/sds_gateway/templates/users/share_group_list.html @@ -36,6 +36,7 @@
{% translate "Your Share Groups" %}
{% block javascript %} {{ block.super }} + " data = { - "template": "users/components/modal_file_tree.html", + "template": "users/components/empty_table_row.html", "context": { - "rows": [ - { - "name": malicious_data, - "type": "File", - "size": "1 MB", - "created_at": "2024-01-01", - "icon": "bi-file", - "icon_color": "text-primary", - "indent_level": 0, - "indent_range": [], - "has_chevron": False, - } - ] + "colspan": 2, + "message": malicious_data, }, } @@ -909,36 +901,27 @@ def test_context_data_is_properly_escaped(self, client: Client) -> None: # Make sure raw script tag is NOT present assert "