diff --git a/docs/plans/20260505-0900-improve-filter-options/plan.md b/docs/plans/20260505-0900-improve-filter-options/plan.md new file mode 100644 index 0000000..889b2db --- /dev/null +++ b/docs/plans/20260505-0900-improve-filter-options/plan.md @@ -0,0 +1,515 @@ +# Plan: Improve Filter Options — Remove Node Filter & Add Observer Multi-Select + +**Date**: 2026-05-05 +**Status**: Draft + +--- + +## Summary + +Two changes to the Advertisements and Messages pages: + +1. **Remove the Node filter** from the Advertisements page (frontend dropdown + API query param `public_key`) +2. **Add an Observer multi-select filter** to both Advertisements and Messages pages, using a standard ``) produce multiple entries with the same name — these need `params.append(k, v)`. + +```js +// Before +export function createFilterHandler(basePath, navigate) { + return (e) => { + 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 queryStr = params.toString(); + navigate(queryStr ? `${basePath}?${queryStr}` : basePath); + }; +} + +// After +export function createFilterHandler(basePath, navigate) { + return (e) => { + e.preventDefault(); + const formData = new FormData(e.target); + const params = new URLSearchParams(); + 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); + }; +} +``` + +Backward-compatible: single-select `` with DaisyUI classes: + +```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` -
-