From 9af90efee4fd3d925ae369a960de8621ca5f1e58 Mon Sep 17 00:00:00 2001 From: Louis King Date: Tue, 5 May 2026 12:22:52 +0100 Subject: [PATCH] feat: add observer multi-select and collapsible filters to list pages - Add observer multi-select (` inside a collapsible filter section (DaisyUI `collapse`, collapsed by default) + +The collapsible section solves the vertical space concern: the multi-select only consumes space when the user expands the filter panel. + +--- + +## Current State + +| Feature | Ads Backend | Ads Frontend | Msgs Backend | Msgs Frontend | +|---|---|---|---|---| +| `public_key` (node filter) | Yes | **Yes (remove)** | N/A | N/A | +| `observed_by` (observer) | Yes (single) | **No** | Yes (single) | **No** | +| Search text | Yes | Yes | Yes | No | +| `since`/`until` timestamps | Yes | No | Yes | No | +| `adopted_by` (member) | Yes | Yes (OIDC cond.) | N/A | N/A | +| `message_type` | N/A | N/A | Yes | Yes | +| `channel_idx` | N/A | N/A | Yes | Yes | +| `pubkey_prefix` (sender) | N/A | N/A | Yes | No | + +### Key files + +- **Advertisements API**: `src/meshcore_hub/api/routes/advertisements.py` — `list_advertisements()` endpoint, query params at lines 42–59 +- **Messages API**: `src/meshcore_hub/api/routes/messages.py` — `list_messages()` endpoint, query params at lines 29–43 +- **Advertisements frontend**: `src/meshcore_hub/web/static/js/spa/pages/advertisements.js` — filter form at lines 179–215, node filter at lines 73–89 +- **Messages frontend**: `src/meshcore_hub/web/static/js/spa/pages/messages.js` — filter form at lines 306–334 +- **Shared components**: `src/meshcore_hub/web/static/js/spa/components.js` — `renderFilterCard` (line 659), `createFilterHandler` (line 555), `pagination` (line 419) +- **Router**: `src/meshcore_hub/web/static/js/spa/router.js` — query parsing at line 100 +- **API client**: `src/meshcore_hub/web/static/js/spa/api.js` — `apiGet` at line 13 +- **i18n**: `src/meshcore_hub/web/static/locales/en.json` +- **DaisyUI collapse**: `node_modules/daisyui/components/collapse.css` — supports both checkbox-based and `
`-based toggling + +--- + +## Change 1: Remove Node Filter from Advertisements + +**Rationale**: The `public_key` filter filters by the originating node. Since ads already show the originating node in the table and users navigate from there, this filter adds little value beyond the existing `search` field (which already matches node names, tag names, and public keys with `ILIKE` wildcards). Removing it simplifies the UI and frees its slot for the observer filter. + +### 1a. Frontend — `advertisements.js` + +Lines to remove/change: +- **Line 14**: Remove `const public_key = query.public_key || '';` +- **Line 54**: Remove `public_key` from `apiParams` → `const apiParams = { limit, offset, search };` +- **Lines 73–89**: Remove the entire `sortedNodes` mapping + `nodesFilter` template block. The `/api/v1/nodes` fetch (line 58) is **kept** and repurposed to populate the observer multi-select (see Change 2e). +- **Lines 188–190**: Remove `if (sortedNodes.length > 0) { filterFields.push(() => nodesFilter); }` +- **Line 176**: Remove `public_key` from pagination params + +### 1b. Backend — `advertisements.py` + +- **Lines 48–49**: Remove `public_key: Optional[str] = Query(None, description="Filter by public key")` +- **Lines 97–98**: Remove `if public_key: query = query.where(Advertisement.public_key == public_key)` + +### 1c. Tests + +- `tests/test_api/test_advertisements.py` — remove or update tests exercising the `public_key` query parameter + +--- + +## Change 2: Add Observer Multi-Select Filter + +**Rationale**: Both APIs already support `observed_by` (filtering by which observer node received the event) as a single-value parameter. Making it multi-value and exposing it in the frontend lets users filter events by one or more observer nodes. Wrapping the filter form in a collapsible section keeps the multi-select from permanently consuming vertical space. + +### 2a. Backend — Both API Routes + +**`advertisements.py`** (lines 50–52, 100–101): +```python +# Before +observed_by: Optional[str] = Query(None, description="Filter by receiver node public key") +# After +observed_by: Optional[list[str]] = Query(None, description="Filter by receiver node public keys") +``` + +```python +# Before +if observed_by: + query = query.where(ObserverNode.public_key == observed_by) +# After +if observed_by: + query = query.where(ObserverNode.public_key.in_(observed_by)) +``` + +**`messages.py`** (lines 36–38, 66–67): Same changes. + +FastAPI natively supports `?observed_by=key1&observed_by=key2` for `list[str]` query params. A single value (`?observed_by=key1`) is parsed as `["key1"]` — fully backward-compatible with `.in_()`. + +### 2b. Router Multi-Value Query Parsing — `router.js` + +**File**: `router.js`, line 100 + +**Problem**: `Object.fromEntries(new URLSearchParams(window.location.search))` **overwrites duplicate keys**. For `?observed_by=a&observed_by=b`, the result is `{ observed_by: "b" }` — only the last value survives. + +**Fix**: Modify query parsing to promote duplicate keys to arrays, keeping single values as strings: + +```js +// Before (line 100) +const query = Object.fromEntries(new URLSearchParams(window.location.search)); + +// After +const sp = new URLSearchParams(window.location.search); +const query = {}; +for (const [k, v] of sp.entries()) { + if (k in query) { + query[k] = Array.isArray(query[k]) ? [...query[k], v] : [query[k], v]; + } else { + query[k] = v; + } +} +``` + +Behavior: `?search=foo` → `{ search: "foo" }` (string). `?observed_by=a&observed_by=b` → `{ observed_by: ["a", "b"] }` (array). `?observed_by=a` → `{ observed_by: "a" }` (string — single values unchanged). Backward-compatible with all existing pages. + +### 2c. API Client Array Params — `api.js` + +**File**: `api.js`, `apiGet` function (line 13–18) + +**Problem**: `url.searchParams.set(k, String(v))` converts `['a','b']` to `"a,b"` instead of separate `observed_by=a&observed_by=b` entries. + +**Fix**: Detect array values and call `.append()` per element: + +```js +// Before +export async function apiGet(path, params = {}) { + const url = new URL(path, window.location.origin); + for (const [k, v] of Object.entries(params)) { + if (v !== null && v !== undefined && v !== '') { + url.searchParams.set(k, String(v)); + } + } + // ... +} + +// After +export async function apiGet(path, params = {}) { + const url = new URL(path, window.location.origin); + for (const [k, v] of Object.entries(params)) { + if (v !== null && v !== undefined && v !== '') { + if (Array.isArray(v)) { + v.forEach(item => url.searchParams.append(k, String(item))); + } else { + url.searchParams.set(k, String(v)); + } + } + } + // ... +} +``` + +### 2d. Pagination Array Params — `components.js` + +**File**: `components.js`, `pagination` function (lines 419–428) + +**Problem**: `encodeURIComponent(v)` on an array serializes to `"a%2Cb"` — wrong. + +**Fix**: Handle array values by appending multiple key-value pairs: + +```js +// Before +for (const [k, v] of Object.entries(params)) { + if (k !== 'page' && v !== null && v !== undefined && v !== '') { + queryParts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`); + } +} + +// After +for (const [k, v] of Object.entries(params)) { + if (k === 'page' || v === null || v === undefined || v === '') continue; + if (Array.isArray(v)) { + v.forEach(item => queryParts.push(`${encodeURIComponent(k)}=${encodeURIComponent(item)}`)); + } else { + queryParts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`); + } +} +``` + +### 2e. Multi-Value Form Handler — `components.js` + +**File**: `components.js`, function `createFilterHandler` (lines 555–566) + +The current handler uses `params.set(k, v)` which overwrites duplicate keys. Multi-value form fields (`` elements produce one entry per key, so `append` with one value behaves identically to `set`. + +### 2f. Observer Multi-Select Field Template + +Standard ` + ${sortedNodes.map(n => html` + + `)} + + +``` + +Key design decisions: +- `size="6"` — shows 6 rows; scrollable when there are more nodes (browser-native scrollbar) +- `max-w-xs` — prevents the select from growing too wide +- No `@change=${autoSubmit}` — user makes selections then clicks the Filter button (inside the collapse) +- Pre-selection via `?selected=` binds to URL state +- Nodes are sorted by display name (same as existing node dropdown in ads) + +### 2g. Collapsible Filter Section — `components.js` + +Modify `renderFilterCard` to accept a `collapsible` option. When enabled, the form is wrapped in a DaisyUI `collapse` component using native `
`/``. + +**DaisyUI collapse with `
`:** + +```html +
+ + Filters + +
+ +
+
+``` + +DaisyUI's collapse CSS responds to the native `[open]` attribute on `
`, with an animated expand/collapse via `grid-template-rows` transition. Clicking the `` toggles `open` natively (no JS, no checkbox). `?open=${defaultOpen}` sets the initial state from lit-html. + +**Updated `renderFilterCard` signature:** + +```js +export function renderFilterCard({ + fields, basePath, navigate, + submitLabel, clearLabel, + collapsible = false, // NEW: wrap in
collapse + defaultOpen = false, // NEW:
when active filters exist +}) { ... } +``` + +**Behavior:** +- When `collapsible: true` and `defaultOpen: false` → collapse starts closed +- When `collapsible: true` and `defaultOpen: true` → collapse starts expanded (user sees active filters) +- Page modules compute `defaultOpen` by checking whether any filter is active (e.g., `search !== '' || observed_by.length > 0 || ...`) +- Clicking the summary title toggles open/closed with DaisyUI's animated transition + +**Structure when collapsible:** +``` +┌──────────────────────────────────────┐ +│ Filters ▼ │ ← (always visible) +├──────────────────────────────────────┤ +│ [search input] [Observer multi] │ ←
+│ [Member select] │ (hidden when closed) +│ [Filter btn] [Clear] │ +└──────────────────────────────────────┘ +``` + +**Implementation for `renderFilterCard`:** + +```js +export function renderFilterCard({ fields, basePath, navigate, submitLabel, clearLabel, collapsible = false, defaultOpen = false }) { + const formBody = html` +
+ ${fields.map(f => f())} +
+ + + ${clearLabel || t('common.clear')} + +
+
+ `; + + if (!collapsible) { + return html` +
+
${formBody}
+
+ `; + } + + return html` +
+ + ${t('common.filters')} + +
+ ${formBody} +
+
+ `; +} +``` + +Note: When the collapse is closed, the form fields are still in the DOM (only visually clipped via `overflow: hidden`). The Filter button is only visible when expanded, so submission only happens with user intent. + +**Collapse state preservation across auto-refresh:** Both pages use `createAutoRefresh` which calls `fetchAndRenderData` periodically, re-rendering the entire page (including the filter card). Without mitigation, `?open=${defaultOpen}` would reset the collapse state on every refresh tick. + +**Fix**: Before re-rendering, read the current `
.open` DOM state and pass it as `defaultOpen`: + +```js +// In fetchAndRenderData, before building the filter card: +const existingDetails = container.querySelector('details.collapse'); +const isFilterOpen = existingDetails ? existingDetails.open : hasActiveFilters; +const filterCard = renderFilterCard({ + fields: filterFields, + basePath: '/advertisements', + navigate, + collapsible: true, + defaultOpen: isFilterOpen, +}); +``` + +This preserves the user's collapse toggle across re-renders. + +### 2h. Frontend — Advertisements Page + +**File**: `advertisements.js` + +- **Extract** `observed_by` from URL (via router, which now preserves multi-value as array): + ```js + const observed_by = query.observed_by + ? (Array.isArray(query.observed_by) ? query.observed_by : [query.observed_by]) + : []; + ``` + +- **Repurpose** the `/api/v1/nodes` fetch (line 58, currently for old node filter) to populate the observer dropdown. Keep `sortedNodes` mapping with `_displayName` and `_sortName` props. + +- **Replace** the old `nodesFilter` ` + ${sortedNodes.map(n => html` + + `)} + +
` + : nothing; + ``` + +- **Update** `filterFields` array — observer field goes where node filter was (between search and member): + ```js + const filterFields = [/* search field */]; + if (sortedNodes.length > 0) { + filterFields.push(() => observerFilter); // note: wrapped in closure for lazy render + } + if (config.oidc_enabled && profiles.length > 0) { + filterFields.push(/* member field */); + } + ``` + +- **Pass** `observed_by` array in API params (now supported by `apiGet`): + ```js + const apiParams = { limit, offset, search }; + if (observed_by.length > 0) { + apiParams.observed_by = observed_by; + } + ``` + +- **Update** pagination to include `observed_by` as array (now supported by `pagination`). + +- **Enable collapsible** mode with state preservation: + ```js + const hasActiveFilters = search !== '' || observed_by.length > 0 || (config.oidc_enabled && adopted_by !== ''); + const existingDetails = container.querySelector('details.collapse'); + const isFilterOpen = existingDetails ? existingDetails.open : hasActiveFilters; + const filterCard = renderFilterCard({ + fields: filterFields, + basePath: '/advertisements', + navigate, + collapsible: true, + defaultOpen: isFilterOpen, + }); + ``` + +### 2i. Frontend — Messages Page + +**File**: `messages.js` + +- **Extract** `observed_by` from URL (same pattern as ads) +- **Add** node fetch: `apiGet('/api/v1/nodes', { limit: 500 })` — messages page currently does NOT fetch extra data +- **Build** same observer ``; enable collapsible mode with state preservation | +| `src/meshcore_hub/web/static/js/spa/pages/messages.js` | Add observer `` unwieldy | Node fetch limited to 500 (same as existing ads page); `size="6"` with scrollbar handles overflow | +| DaisyUI collapse animation performance | CSS `grid-template-rows` transition with `prefers-reduced-motion` support — well-behaved | diff --git a/docs/plans/20260505-0900-improve-filter-options/tasks.md b/docs/plans/20260505-0900-improve-filter-options/tasks.md new file mode 100644 index 0000000..cb7a45f --- /dev/null +++ b/docs/plans/20260505-0900-improve-filter-options/tasks.md @@ -0,0 +1,110 @@ +# Tasks — Improve Filter Options + +## Phase 1: Backend API Changes + +- [x] **1.1** Remove `public_key` filter from `src/meshcore_hub/api/routes/advertisements.py` + - Remove `public_key: Optional[str] = Query(...)` param declaration (lines 48–49) + - Remove `if public_key: query = query.where(Advertisement.public_key == public_key)` WHERE clause (lines 97–98) + +- [x] **1.2** Change `observed_by` to `list[str]` in `src/meshcore_hub/api/routes/advertisements.py` + - Change type: `Optional[str]` → `Optional[list[str]]` (line 50) + - Change WHERE: `== observed_by` → `.in_(observed_by)` (line 101) + - Update `asyncio.gather` to remove `public_key` from fetch if present (lines 85–96, review only) + +- [x] **1.3** Change `observed_by` to `list[str]` in `src/meshcore_hub/api/routes/messages.py` + - Change type: `Optional[str]` → `Optional[list[str]]` (line 36) + - Change WHERE: `== observed_by` → `.in_(observed_by)` (line 67) + +## Phase 2: Frontend Infrastructure Fixes + +- [x] **2.1** Fix router query parsing in `src/meshcore_hub/web/static/js/spa/router.js` (line 100) + - Replace `Object.fromEntries(new URLSearchParams(...))` with loop that promotes duplicate keys to arrays + - Single values remain strings; duplicate keys become `[value1, value2]` + +- [x] **2.2** Add array param support to `apiGet` in `src/meshcore_hub/web/static/js/spa/api.js` (line 17) + - Detect `Array.isArray(v)` and call `url.searchParams.append(k, String(item))` per element + - Non-array values pass through existing `url.searchParams.set(k, String(v))` + +- [x] **2.3** Add array param support to `pagination` in `src/meshcore_hub/web/static/js/spa/components.js` (lines 423–427) + - Same array-handling pattern: `Array.isArray(v)` → append per element, otherwise `encodeURIComponent` + +- [x] **2.4** Fix `createFilterHandler` for multi-value in `src/meshcore_hub/web/static/js/spa/components.js` (lines 558–562) + - Replace `params.set(k, v)` with `params.append(k, v)` + - Iterate `new Set(formData.keys())` to get unique keys, then use `formData.getAll(k)` per key + +## Phase 3: Collapsible Filter Section + +- [x] **3.1** Update `renderFilterCard` signature in `src/meshcore_hub/web/static/js/spa/components.js` (line 659) + - Add `collapsible = false` and `defaultOpen = false` parameters + +- [x] **3.2** Implement collapsible rendering in `renderFilterCard` + - When `!collapsible`: render existing card layout unchanged + - When `collapsible`: wrap form in `
` with `${t('common.filters')}` and form in `
` + +## Phase 4: Advertisements Page + +- [x] **4.1** Remove node filter from `src/meshcore_hub/web/static/js/spa/pages/advertisements.js` + - Remove `const public_key = query.public_key || '';` (line 14) + - Remove `public_key` from `apiParams` (`apiParams.append` → direct object property) (line 54) + - Remove `public_key` from pagination params (line 176) + - Remove `nodesFilter` template block (lines 78–89) + - Keep `/api/v1/nodes` fetch (line 58) and `sortedNodes` mapping (lines 73–76) for observer dropdown + +- [x] **4.2** Add observer multi-select to advertisements page + - Extract `observed_by` from router query: handle both string and array (guard with `Array.isArray()`) + - Add observer `` template using `sortedNodes` + - Push observer field to `filterFields` array (after type and channel selects) + - Pass `observed_by` array in `apiParams` when non-empty + - Include `observed_by` in pagination params + +- [x] **5.3** Enable collapsible mode with state preservation on messages page + - Compute `hasActiveFilters` from `message_type`, `channel_idx`, and `observed_by.length` + - Before each re-render, read `container.querySelector('details.collapse').open` from DOM + - Pass `collapsible: true` and `defaultOpen: isFilterOpen` to `renderFilterCard` + +## Phase 6: i18n + +- [x] **6.1** Add new keys to `src/meshcore_hub/web/static/locales/en.json` under `"common"`: + - `"filters": "Filters"` — collapse title + - `"filter_observer_label": "Observer"` — multi-select label + +## Phase 7: Tests + +- [x] **7.1** Update `tests/test_api/test_advertisements.py` + - Remove tests exercising the `public_key` query parameter + - Add test: single observer filter returns matching ads + - Add test: multiple observer filter returns ads from any matching observer + +- [x] **7.2** Update `tests/test_api/test_messages.py` + - Add test: single observer filter returns matching messages + - Add test: multiple observer filter returns messages from any matching observer + +## Verification + +- [x] Run `pytest tests/test_api/test_advertisements.py -v` +- [x] Run `pytest tests/test_api/test_messages.py -v` +- [x] Manually verify in browser: + - Advertisements page: no node filter, observer multi-select in collapsible section + - Messages page: observer multi-select in collapsible section + - Multi-value selection → URL reflects `?observed_by=a&observed_by=b` + - Pagination preserves observer params + - Auto-refresh preserves collapse state + - Backward-compatible: single-select filters still work via Filter button +- [x] Run `pre-commit run --all-files` diff --git a/src/meshcore_hub/api/routes/advertisements.py b/src/meshcore_hub/api/routes/advertisements.py index 7ff5184..f739d0f 100644 --- a/src/meshcore_hub/api/routes/advertisements.py +++ b/src/meshcore_hub/api/routes/advertisements.py @@ -46,9 +46,8 @@ async def list_advertisements( search: Optional[str] = Query( None, description="Search in name tag, node name, or public key" ), - public_key: Optional[str] = Query(None, description="Filter by public key"), - observed_by: Optional[str] = Query( - None, description="Filter by receiver node public key" + observed_by: Optional[list[str]] = Query( + None, description="Filter by receiver node public keys" ), adopted_by: Optional[str] = Query( None, description="Filter by adopting user profile UUID" @@ -94,11 +93,8 @@ async def list_advertisements( ) ) - if public_key: - query = query.where(Advertisement.public_key == public_key) - if observed_by: - query = query.where(ObserverNode.public_key == observed_by) + query = query.where(ObserverNode.public_key.in_(observed_by)) if adopted_by: query = query.where( diff --git a/src/meshcore_hub/api/routes/messages.py b/src/meshcore_hub/api/routes/messages.py index 701cd8c..d8b6af4 100644 --- a/src/meshcore_hub/api/routes/messages.py +++ b/src/meshcore_hub/api/routes/messages.py @@ -33,8 +33,8 @@ async def list_messages( message_type: Optional[str] = Query(None, description="Filter by message type"), pubkey_prefix: Optional[str] = Query(None, description="Filter by sender prefix"), channel_idx: Optional[int] = Query(None, description="Filter by channel"), - observed_by: Optional[str] = Query( - None, description="Filter by receiver node public key" + observed_by: Optional[list[str]] = Query( + None, description="Filter by receiver node public keys" ), since: Optional[datetime] = Query(None, description="Start timestamp"), until: Optional[datetime] = Query(None, description="End timestamp"), @@ -64,7 +64,7 @@ async def list_messages( query = query.where(Message.channel_idx == channel_idx) if observed_by: - query = query.where(ObserverNode.public_key == observed_by) + query = query.where(ObserverNode.public_key.in_(observed_by)) if since: query = query.where(Message.received_at >= since) diff --git a/src/meshcore_hub/api/routes/nodes.py b/src/meshcore_hub/api/routes/nodes.py index 9725f28..4bd5b76 100644 --- a/src/meshcore_hub/api/routes/nodes.py +++ b/src/meshcore_hub/api/routes/nodes.py @@ -8,7 +8,16 @@ from sqlalchemy.orm import selectinload from meshcore_hub.api.auth import RequireRead from meshcore_hub.api.dependencies import DbSession -from meshcore_hub.common.models import Node, NodeTag, UserProfileNode +from meshcore_hub.common.models import ( + Advertisement, + EventObserver, + Message, + Node, + NodeTag, + Telemetry, + TracePath, + UserProfileNode, +) from meshcore_hub.common.schemas.nodes import AdoptedByUser, NodeList, NodeRead router = APIRouter() @@ -46,6 +55,9 @@ async def list_nodes( None, description="Filter by adopting user profile UUID" ), role: Optional[str] = Query(None, description="Filter by role tag value"), + observer: Optional[bool] = Query( + None, description="Filter to nodes that have observed events" + ), limit: int = Query(50, ge=1, le=500, description="Page size"), offset: int = Query(0, ge=0, description="Page offset"), ) -> NodeList: @@ -128,6 +140,58 @@ async def list_nodes( ) ) + if observer is not None: + if observer: + query = query.where( + or_( + Node.id.in_( + select(Advertisement.observer_node_id).where( + Advertisement.observer_node_id.is_not(None) + ) + ), + Node.id.in_( + select(Message.observer_node_id).where( + Message.observer_node_id.is_not(None) + ) + ), + Node.id.in_( + select(Telemetry.observer_node_id).where( + Telemetry.observer_node_id.is_not(None) + ) + ), + Node.id.in_( + select(TracePath.observer_node_id).where( + TracePath.observer_node_id.is_not(None) + ) + ), + Node.id.in_(select(EventObserver.observer_node_id)), + ) + ) + else: + query = query.where( + ~Node.id.in_( + select(Advertisement.observer_node_id).where( + Advertisement.observer_node_id.is_not(None) + ) + ), + ~Node.id.in_( + select(Message.observer_node_id).where( + Message.observer_node_id.is_not(None) + ) + ), + ~Node.id.in_( + select(Telemetry.observer_node_id).where( + Telemetry.observer_node_id.is_not(None) + ) + ), + ~Node.id.in_( + select(TracePath.observer_node_id).where( + TracePath.observer_node_id.is_not(None) + ) + ), + ~Node.id.in_(select(EventObserver.observer_node_id)), + ) + # Get total count count_query = select(func.count()).select_from(query.subquery()) total = session.execute(count_query).scalar() or 0 diff --git a/src/meshcore_hub/web/static/js/spa/api.js b/src/meshcore_hub/web/static/js/spa/api.js index cf8c2be..7ce0a14 100644 --- a/src/meshcore_hub/web/static/js/spa/api.js +++ b/src/meshcore_hub/web/static/js/spa/api.js @@ -14,7 +14,11 @@ export async function apiGet(path, params = {}) { const url = new URL(path, window.location.origin); for (const [k, v] of Object.entries(params)) { if (v !== null && v !== undefined && v !== '') { - url.searchParams.set(k, String(v)); + if (Array.isArray(v)) { + v.forEach(item => url.searchParams.append(k, String(item))); + } else { + url.searchParams.set(k, String(v)); + } } } const response = await fetch(url); diff --git a/src/meshcore_hub/web/static/js/spa/components.js b/src/meshcore_hub/web/static/js/spa/components.js index 054b79e..1112ee6 100644 --- a/src/meshcore_hub/web/static/js/spa/components.js +++ b/src/meshcore_hub/web/static/js/spa/components.js @@ -421,7 +421,10 @@ export function pagination(page, totalPages, basePath, params = {}) { const queryParts = []; for (const [k, v] of Object.entries(params)) { - if (k !== 'page' && v !== null && v !== undefined && v !== '') { + if (k === 'page' || v === null || v === undefined || v === '') continue; + if (Array.isArray(v)) { + v.forEach(item => queryParts.push(`${encodeURIComponent(k)}=${encodeURIComponent(item)}`)); + } else { queryParts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`); } } @@ -557,8 +560,11 @@ export function createFilterHandler(basePath, navigate) { e.preventDefault(); const formData = new FormData(e.target); const params = new URLSearchParams(); - for (const [k, v] of formData.entries()) { - if (v) params.set(k, v); + const keys = new Set(formData.keys()); + for (const k of keys) { + for (const v of formData.getAll(k)) { + if (v) params.append(k, v); + } } const queryStr = params.toString(); navigate(queryStr ? `${basePath}?${queryStr}` : basePath); @@ -654,21 +660,41 @@ export function renderAuthSection(container, config) { * @param {Function} options.navigate - Router navigate function * @param {string} [options.submitLabel] - Text for submit button (default: translated "Filter") * @param {string} [options.clearLabel] - Text for clear button (default: translated "Clear") + * @param {boolean} [options.collapsible=false] - Wrap in DaisyUI collapsible
+ * @param {boolean} [options.defaultOpen=false] - Start expanded when collapsible * @returns {TemplateResult} */ -export function renderFilterCard({ fields, basePath, navigate, submitLabel, clearLabel }) { - return html` -
-
-
- ${fields.map(f => f())} -
- - ${clearLabel || t('common.clear')} -
-
+export function renderFilterCard({ fields, basePath, navigate, submitLabel, clearLabel, collapsible = false, defaultOpen = false }) { + const formBody = html` +
+
+ ${fields.map(f => f())}
-
+
+ + ${clearLabel || t('common.clear')} +
+ + `; + + if (!collapsible) { + return html` +
+
${formBody}
+
+ `; + } + + return html` +
+ + ${t('common.filters')} + +
+ ${formBody} +
+
`; } diff --git a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js index 74634dc..1a6028a 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js +++ b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js @@ -11,7 +11,9 @@ import { createAutoRefresh } from '../auto-refresh.js'; export async function render(container, params, router) { const query = params.query || {}; const search = query.search || ''; - const public_key = query.public_key || ''; + const observed_by = query.observed_by + ? (Array.isArray(query.observed_by) ? query.observed_by : [query.observed_by]) + : []; const adopted_by = query.adopted_by || ''; const page = parseInt(query.page, 10) || 1; const limit = parseInt(query.limit, 10) || 20; @@ -51,11 +53,12 @@ ${displayContent}`, container); async function fetchAndRenderData() { try { - const apiParams = { limit, offset, search, public_key }; + const apiParams = { limit, offset, search }; + if (observed_by.length > 0) apiParams.observed_by = observed_by; if (adopted_by) apiParams.adopted_by = adopted_by; const fetches = [ apiGet('/api/v1/advertisements', apiParams), - apiGet('/api/v1/nodes', { limit: 500 }), + apiGet('/api/v1/nodes', { limit: 500, observer: true }), ]; if (config.oidc_enabled) { fetches.push(apiGet('/api/v1/user/profiles', { limit: 500 })); @@ -77,13 +80,18 @@ ${displayContent}`, container); const nodesFilter = sortedNodes.length > 0 ? html` -
-