mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-07-02 07:51:07 +02:00
feat: change default nodes sort to last_seen DESC and add mobile sort controls
- Change API and frontend default sort from name/asc to last_seen/desc - Add mobileSortSelect() shared component for native select dropdown - Add mobile sort select to nodes, advertisements, and messages pages - Add i18n sort labels for all three list pages - Update sort tests for new default with staggered timestamps
This commit is contained in:
+43
-1
@@ -182,6 +182,7 @@ Toast/flash messages after successful operations:
|
||||
| `snr_db` | SNR (dB) | Signal-to-noise ratio in decibels (table header) |
|
||||
| `unnamed` | Unnamed | Fallback for unnamed items |
|
||||
| `unnamed_node` | Unnamed Node | Fallback for unnamed nodes |
|
||||
| `sort_by` | Sort by | Label before mobile sort dropdown |
|
||||
|
||||
**Note:** Keys ending in `_label` have colons and are used inline. Keys without `_label` are for table headers.
|
||||
|
||||
@@ -280,9 +281,35 @@ Node detail page labels:
|
||||
| `release_success` | Node released successfully | Flash message after releasing |
|
||||
| `release_confirm` | Are you sure you want to release this node? | Confirmation dialog for release |
|
||||
|
||||
#### Sort Options (`nodes.sort`)
|
||||
|
||||
Mobile sort dropdown labels for the nodes list page:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `sort.last_seen_newest` | Last Seen (newest) | Default sort: most recently seen first |
|
||||
| `sort.last_seen_oldest` | Last Seen (oldest) | Least recently seen first |
|
||||
| `sort.name_az` | Name (A–Z) | Alphabetical by display name |
|
||||
| `sort.name_za` | Name (Z–A) | Reverse alphabetical by display name |
|
||||
| `sort.key_asc` | Public Key (ascending) | Sorted by public key ascending |
|
||||
| `sort.key_desc` | Public Key (descending) | Sorted by public key descending |
|
||||
|
||||
### 10. `advertisements`
|
||||
|
||||
Currently empty - advertisements page uses common patterns.
|
||||
Sort options for the advertisements list page:
|
||||
|
||||
#### Sort Options (`advertisements.sort`)
|
||||
|
||||
Mobile sort dropdown labels for the advertisements list page:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `sort.newest` | Time (newest) | Default sort: most recent first |
|
||||
| `sort.oldest` | Time (oldest) | Oldest first |
|
||||
| `sort.node_az` | Node (A–Z) | Alphabetical by node display name |
|
||||
| `sort.node_za` | Node (Z–A) | Reverse alphabetical by node display name |
|
||||
| `sort.key_asc` | Public Key (ascending) | Sorted by public key ascending |
|
||||
| `sort.key_desc` | Public Key (descending) | Sorted by public key descending |
|
||||
|
||||
### 11. `messages`
|
||||
|
||||
@@ -295,6 +322,21 @@ Message type labels:
|
||||
| `type_contact` | Contact | Contact message type |
|
||||
| `type_public` | Public | Public message type |
|
||||
|
||||
#### Sort Options (`messages.sort`)
|
||||
|
||||
Mobile sort dropdown labels for the messages list page:
|
||||
|
||||
| Key | English | Context |
|
||||
|-----|---------|---------|
|
||||
| `sort.newest` | Time (newest) | Default sort: most recent first |
|
||||
| `sort.oldest` | Time (oldest) | Oldest first |
|
||||
| `sort.type_az` | Type (A–Z) | Alphabetical by message type |
|
||||
| `sort.type_za` | Type (Z–A) | Reverse alphabetical by message type |
|
||||
| `sort.from_az` | From (A–Z) | Alphabetical by sender name |
|
||||
| `sort.from_za` | From (Z–A) | Reverse alphabetical by sender name |
|
||||
| `sort.message_az` | Message (A–Z) | Alphabetical by message text |
|
||||
| `sort.message_za` | Message (Z–A) | Reverse alphabetical by message text |
|
||||
|
||||
### 12. `map`
|
||||
|
||||
Map page content:
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
# Plan: Default Nodes Sort by Time + Mobile Sort Controls
|
||||
|
||||
**Date:** 2026-05-05
|
||||
**Branch:** `feat/mobile-sort-time-default`
|
||||
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
Two issues with the current sorting UX:
|
||||
|
||||
1. **Nodes default sort is alphabetical by name** (`sort=name, order=asc`). For a mesh network dashboard, sorting by most recently seen is more useful — operators want to see active nodes first.
|
||||
|
||||
2. **Mobile view has no sort controls.** The desktop table view has clickable column headers, but the mobile card view (`lg:hidden`) renders a separate card layout with no sort mechanism. Mobile users cannot change the sort order at all.
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Change Default Nodes Sort to Last Seen (Descending)
|
||||
|
||||
### 1A. API — `src/meshcore_hub/api/routes/nodes.py`
|
||||
|
||||
Change lines 205–206:
|
||||
|
||||
```python
|
||||
# Before
|
||||
sort = sort if sort in VALID_NODE_SORT_COLUMNS else "name"
|
||||
order = order if order in ("asc", "desc") else ("asc" if sort == "name" else "desc")
|
||||
|
||||
# After
|
||||
sort = sort if sort in VALID_NODE_SORT_COLUMNS else "last_seen"
|
||||
order = order if order in ("asc", "desc") else "desc"
|
||||
```
|
||||
|
||||
The default when no `sort`/`order` params are provided is now `last_seen DESC` (newest first).
|
||||
|
||||
### 1B. Frontend — `src/meshcore_hub/web/static/js/spa/pages/nodes.js`
|
||||
|
||||
Change lines 19–20:
|
||||
|
||||
```javascript
|
||||
// Before
|
||||
const sort = query.sort || 'name';
|
||||
const order = query.order || 'asc';
|
||||
|
||||
// After
|
||||
const sort = query.sort || 'last_seen';
|
||||
const order = query.order || 'desc';
|
||||
```
|
||||
|
||||
This ensures the "Last Seen" column header shows the correct ▾ indicator on first load.
|
||||
|
||||
### 1C. Tests
|
||||
|
||||
Update existing sort tests in `tests/test_api/test_nodes.py` that assume `name` default:
|
||||
|
||||
- **`test_sort_by_name_default`** (line 472): Change name/docstring to `test_sort_by_last_seen_default`. Both nodes currently get identical `last_seen=datetime.now(timezone.utc)` timestamps — they must be given **staggered** timestamps (e.g., 1 hour apart) so the `last_seen DESC` default produces a deterministic order. Assert newest-first (`node_b` first if it has the later timestamp).
|
||||
|
||||
- **`test_sort_invalid_ignored`** (line 643): Update docstring from "falls back to default (name alpha)" to "**falls back to default (last_seen desc)**". Both nodes currently have `last_seen=NULL` (only `first_seen` is set), so the sort order would be non-deterministic. Give the nodes staggered `last_seen` timestamps and assert the newest-first ordering.
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Mobile Sort Select Dropdown
|
||||
|
||||
### Design
|
||||
|
||||
A compact native `<select>` dropdown shown only on mobile (below `lg` breakpoint), positioned between the stats row and the card list. Combines sort column and direction into a single control for minimal UI footprint.
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ Nodes 🕐 30s │ ← page title + auto-refresh
|
||||
│ 42 total │ ← stats badges
|
||||
│ │
|
||||
│ Sort: [Last Seen (newest) ▾] │ ← NEW: mobile sort select
|
||||
│ │
|
||||
│ ┌────────────────────────┐ │
|
||||
│ │ Node ABC 2 min ago │ │ ← mobile cards
|
||||
│ └────────────────────────┘ │
|
||||
│ ┌────────────────────────┐ │
|
||||
│ │ Node XYZ 5 min ago │ │
|
||||
│ └────────────────────────┘ │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
### Why native `<select>`
|
||||
|
||||
| Factor | Native `<select>` | Pill chips | Dropdown menu |
|
||||
|--------|-------------------|------------|---------------|
|
||||
| Touch optimization | Native iOS/Android picker | Custom tap targets | Custom tap targets |
|
||||
| UI footprint | 1 line | 1–2 lines | 1 line + overlay |
|
||||
| Discoverability | All options visible in picker | Only visible pills | Hidden until opened |
|
||||
| Consistency | Matches filter `<select>` already used | New pattern | New pattern |
|
||||
| Accessibility | Built-in | Needs ARIA | Needs ARIA |
|
||||
|
||||
### 2A. Shared component — `src/meshcore_hub/web/static/js/spa/components.js`
|
||||
|
||||
Add a `mobileSortSelect()` function. It calls `buildSortUrl()` (line 18), which is a **module-scoped (non-exported) function** in the same file — accessible via closure scope, no export needed.
|
||||
|
||||
```javascript
|
||||
export function mobileSortSelect({ currentSort, currentOrder, navigate, basePath, params, options }) {
|
||||
const currentValue = `${currentSort}:${currentOrder}`;
|
||||
|
||||
const sortOptions = options.map(opt =>
|
||||
html`<option value=${opt.value} ?selected=${opt.value === currentValue}>${opt.label}</option>`
|
||||
);
|
||||
|
||||
const onChange = (e) => {
|
||||
const [sort, order] = e.target.value.split(':');
|
||||
const url = buildSortUrl(basePath, params, sort, order);
|
||||
navigate(url);
|
||||
};
|
||||
|
||||
return html`<div class="lg:hidden mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs opacity-60">${t('common.sort_by')}</span>
|
||||
<select class="select select-sm select-bordered flex-1"
|
||||
@change=${onChange}>
|
||||
${sortOptions}
|
||||
</select>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `currentSort` / `currentOrder`: current sort state from URL
|
||||
- `navigate`: SPA router navigate function
|
||||
- `basePath`: e.g., `'/nodes'`
|
||||
- `params`: filter params to preserve
|
||||
- `options`: array of `{ value: 'sort:order', label: 'Display Name' }` objects
|
||||
|
||||
### 2B. Nodes page — `src/meshcore_hub/web/static/js/spa/pages/nodes.js`
|
||||
|
||||
Add the mobile sort select between the stats row and the card list:
|
||||
|
||||
```javascript
|
||||
import { mobileSortSelect } from '../components.js';
|
||||
|
||||
// Define sort options
|
||||
const sortOptions = [
|
||||
{ value: 'last_seen:desc', label: t('nodes.sort.last_seen_newest') },
|
||||
{ value: 'last_seen:asc', label: t('nodes.sort.last_seen_oldest') },
|
||||
{ value: 'name:asc', label: t('nodes.sort.name_az') },
|
||||
{ value: 'name:desc', label: t('nodes.sort.name_za') },
|
||||
{ value: 'public_key:asc', label: t('nodes.sort.key_asc') },
|
||||
{ value: 'public_key:desc', label: t('nodes.sort.key_desc') },
|
||||
];
|
||||
|
||||
// In the render, between stats badges and mobile cards:
|
||||
${mobileSortSelect({
|
||||
currentSort: sort, currentOrder: order,
|
||||
navigate, basePath: '/nodes',
|
||||
params: headerParams, options: sortOptions,
|
||||
})}
|
||||
```
|
||||
|
||||
### 2C. Advertisements page — `src/meshcore_hub/web/static/js/spa/pages/advertisements.js`
|
||||
|
||||
Same pattern, with ad-specific sort options:
|
||||
|
||||
```javascript
|
||||
const sortOptions = [
|
||||
{ value: 'time:desc', label: t('advertisements.sort.newest') },
|
||||
{ value: 'time:asc', label: t('advertisements.sort.oldest') },
|
||||
{ value: 'node_name:asc', label: t('advertisements.sort.node_az') },
|
||||
{ value: 'node_name:desc', label: t('advertisements.sort.node_za') },
|
||||
{ value: 'public_key:asc', label: t('advertisements.sort.key_asc') },
|
||||
{ value: 'public_key:desc', label: t('advertisements.sort.key_desc') },
|
||||
];
|
||||
```
|
||||
|
||||
### 2D. Messages page — `src/meshcore_hub/web/static/js/spa/pages/messages.js`
|
||||
|
||||
Same pattern, with message-specific sort options:
|
||||
|
||||
```javascript
|
||||
const sortOptions = [
|
||||
{ value: 'time:desc', label: t('messages.sort.newest') },
|
||||
{ value: 'time:asc', label: t('messages.sort.oldest') },
|
||||
{ value: 'type:asc', label: t('messages.sort.type_az') },
|
||||
{ value: 'type:desc', label: t('messages.sort.type_za') },
|
||||
{ value: 'from:asc', label: t('messages.sort.from_az') },
|
||||
{ value: 'from:desc', label: t('messages.sort.from_za') },
|
||||
{ value: 'message:asc', label: t('messages.sort.message_az') },
|
||||
{ value: 'message:desc', label: t('messages.sort.message_za') },
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## i18n Changes
|
||||
|
||||
### `src/meshcore_hub/web/static/locales/en.json`
|
||||
|
||||
Add new keys:
|
||||
|
||||
```json
|
||||
{
|
||||
"common": {
|
||||
"sort_by": "Sort by"
|
||||
},
|
||||
"nodes": {
|
||||
"sort": {
|
||||
"last_seen_newest": "Last Seen (newest)",
|
||||
"last_seen_oldest": "Last Seen (oldest)",
|
||||
"name_az": "Name (A\u2013Z)",
|
||||
"name_za": "Name (Z\u2013A)",
|
||||
"key_asc": "Public Key (ascending)",
|
||||
"key_desc": "Public Key (descending)"
|
||||
}
|
||||
},
|
||||
"advertisements": {
|
||||
"sort": {
|
||||
"newest": "Time (newest)",
|
||||
"oldest": "Time (oldest)",
|
||||
"node_az": "Node (A\u2013Z)",
|
||||
"node_za": "Node (Z\u2013A)",
|
||||
"key_asc": "Public Key (ascending)",
|
||||
"key_desc": "Public Key (descending)"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"sort": {
|
||||
"newest": "Time (newest)",
|
||||
"oldest": "Time (oldest)",
|
||||
"type_az": "Type (A\u2013Z)",
|
||||
"type_za": "Type (Z\u2013A)",
|
||||
"from_az": "From (A\u2013Z)",
|
||||
"from_za": "From (Z\u2013A)",
|
||||
"message_az": "Message (A\u2013Z)",
|
||||
"message_za": "Message (Z\u2013A)"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/meshcore_hub/api/routes/nodes.py` | Change default sort from `name ASC` to `last_seen DESC` |
|
||||
| `src/meshcore_hub/web/static/js/spa/pages/nodes.js` | Change frontend default sort; add mobile sort select |
|
||||
| `src/meshcore_hub/web/static/js/spa/pages/advertisements.js` | Add mobile sort select |
|
||||
| `src/meshcore_hub/web/static/js/spa/pages/messages.js` | Add mobile sort select |
|
||||
| `src/meshcore_hub/web/static/js/spa/components.js` | Add `mobileSortSelect()` export |
|
||||
| `src/meshcore_hub/web/static/locales/en.json` | Add sort option translation keys |
|
||||
| `docs/i18n.md` | Document new translation keys |
|
||||
| `tests/test_api/test_nodes.py` | Update default sort test expectations |
|
||||
|
||||
---
|
||||
|
||||
## Sequence
|
||||
|
||||
1. Change default sort in API (`nodes.py`)
|
||||
2. Change frontend default in `nodes.js`
|
||||
3. Add `mobileSortSelect()` to `components.js`
|
||||
4. Add mobile sort select to `nodes.js`
|
||||
5. Add mobile sort select to `advertisements.js`
|
||||
6. Add mobile sort select to `messages.js`
|
||||
7. Add i18n keys to `en.json` and update `docs/i18n.md`
|
||||
8. Update API tests
|
||||
9. Run `pytest tests/test_api/`
|
||||
10. Run `pre-commit run --all-files`
|
||||
@@ -0,0 +1,74 @@
|
||||
# Tasks: Default Nodes Sort by Time + Mobile Sort Controls
|
||||
|
||||
**Plan:** [plan.md](plan.md)
|
||||
**Branch:** `feat/mobile-sort-time-default`
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Change Default Nodes Sort to Last Seen (Descending)
|
||||
|
||||
- [ ] **1.1** Update API default sort in `src/meshcore_hub/api/routes/nodes.py` (lines 205–206)
|
||||
- Change `else "name"` → `else "last_seen"`
|
||||
- Change `else ("asc" if sort == "name" else "desc")` → `else "desc"`
|
||||
|
||||
- [ ] **1.2** Update frontend default sort in `src/meshcore_hub/web/static/js/spa/pages/nodes.js` (lines 19–20)
|
||||
- Change `query.sort || 'name'` → `query.sort || 'last_seen'`
|
||||
- Change `query.order || 'asc'` → `query.order || 'desc'`
|
||||
|
||||
- [ ] **1.3** Update existing tests in `tests/test_api/test_nodes.py`
|
||||
- **`test_sort_by_name_default`** (line 472): Rename to `test_sort_by_last_seen_default`
|
||||
- Give the two test nodes **staggered** `last_seen` timestamps (e.g., 1 hour apart) instead of identical `datetime.now(timezone.utc)`
|
||||
- Assert newest-first order (`node_b` first if it has the later timestamp)
|
||||
- **`test_sort_invalid_ignored`** (line 643): Update docstring to "falls back to default (last_seen desc)"
|
||||
- Give the two test nodes staggered `last_seen` timestamps and assert newest-first ordering
|
||||
|
||||
## Phase 2: Shared Mobile Sort Component
|
||||
|
||||
- [ ] **2.1** Add `mobileSortSelect()` export in `src/meshcore_hub/web/static/js/spa/components.js`
|
||||
- Parameters: `{ currentSort, currentOrder, navigate, basePath, params, options }`
|
||||
- Renders `lg:hidden` wrapper with label + native `<select>`
|
||||
- Calls `buildSortUrl()` (module-scoped, line 18) via closure on change
|
||||
- Splits `option.value` on `:` to extract sort/order, navigates via SPA router
|
||||
- Mark currently-selected option with `?selected`
|
||||
|
||||
## Phase 3: Mobile Sort Select on Each List Page
|
||||
|
||||
- [ ] **3.1** Add mobile sort select to `src/meshcore_hub/web/static/js/spa/pages/nodes.js`
|
||||
- Import `mobileSortSelect` from `../components.js`
|
||||
- Define sort options: `last_seen:desc`, `last_seen:asc`, `name:asc`, `name:desc`, `public_key:asc`, `public_key:desc`
|
||||
- Insert `${mobileSortSelect(...)}` between stats badges and mobile card list
|
||||
- Pass `headerParams` to preserve filter state
|
||||
|
||||
- [ ] **3.2** Add mobile sort select to `src/meshcore_hub/web/static/js/spa/pages/advertisements.js`
|
||||
- Import `mobileSortSelect` from `../components.js`
|
||||
- Define sort options: `time:desc`, `time:asc`, `node_name:asc`, `node_name:desc`, `public_key:asc`, `public_key:desc`
|
||||
- Insert between stats badges and mobile card list
|
||||
|
||||
- [ ] **3.3** Add mobile sort select to `src/meshcore_hub/web/static/js/spa/pages/messages.js`
|
||||
- Import `mobileSortSelect` from `../components.js`
|
||||
- Define sort options: `time:desc`, `time:asc`, `type:asc`, `type:desc`, `from:asc`, `from:desc`, `message:asc`, `message:desc`
|
||||
- Insert between stats badges and mobile card list
|
||||
|
||||
## Phase 4: i18n
|
||||
|
||||
- [ ] **4.1** Add sort translation keys to `src/meshcore_hub/web/static/locales/en.json`
|
||||
- Add `common.sort_by`: "Sort by"
|
||||
- Add `nodes.sort.*` (6 keys): `last_seen_newest`, `last_seen_oldest`, `name_az`, `name_za`, `key_asc`, `key_desc`
|
||||
- Add `advertisements.sort.*` (6 keys): `newest`, `oldest`, `node_az`, `node_za`, `key_asc`, `key_desc`
|
||||
- Add `messages.sort.*` (8 keys): `newest`, `oldest`, `type_az`, `type_za`, `from_az`, `from_za`, `message_az`, `message_za`
|
||||
|
||||
- [ ] **4.2** Document new keys in `docs/i18n.md`
|
||||
|
||||
## Verification
|
||||
|
||||
- [ ] Run `pytest tests/test_api/test_nodes.py -v`
|
||||
- [ ] Run `pytest tests/test_common/test_i18n.py -v`
|
||||
- [ ] Run `pre-commit run --all-files`
|
||||
- [ ] Visual verification in browser:
|
||||
- Nodes: default sort shows newest-first; Last Seen column header shows ▾ on first load
|
||||
- Nodes mobile: sort select visible below `lg` breakpoint; changing it updates the card order
|
||||
- Advertisements mobile: sort select with time/node/key options
|
||||
- Messages mobile: sort select with time/type/from/message options
|
||||
- Desktop view: no sort select visible (hidden at `lg:` breakpoint)
|
||||
- Auto-refresh preserves sort state across ticks
|
||||
- Filter change preserves sort state
|
||||
@@ -202,8 +202,8 @@ async def list_nodes(
|
||||
total = session.execute(count_query).scalar() or 0
|
||||
|
||||
# Resolve sort column and direction
|
||||
sort = sort if sort in VALID_NODE_SORT_COLUMNS else "name"
|
||||
order = order if order in ("asc", "desc") else ("asc" if sort == "name" else "desc")
|
||||
sort = sort if sort in VALID_NODE_SORT_COLUMNS else "last_seen"
|
||||
order = order if order in ("asc", "desc") else "desc"
|
||||
|
||||
name_tag_subq = (
|
||||
select(NodeTag.value)
|
||||
|
||||
@@ -58,6 +58,30 @@ export function sortableTableHeader(label, { sortKey, currentSort, currentOrder,
|
||||
</th>`;
|
||||
}
|
||||
|
||||
export function mobileSortSelect({ currentSort, currentOrder, navigate, basePath, params, options }) {
|
||||
const currentValue = `${currentSort}:${currentOrder}`;
|
||||
|
||||
const sortOptions = options.map(opt =>
|
||||
html`<option value=${opt.value} ?selected=${opt.value === currentValue}>${opt.label}</option>`
|
||||
);
|
||||
|
||||
const onChange = (e) => {
|
||||
const [sort, order] = e.target.value.split(':');
|
||||
const url = buildSortUrl(basePath, params, sort, order);
|
||||
navigate(url);
|
||||
};
|
||||
|
||||
return html`<div class="lg:hidden mb-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs opacity-60">${t('common.sort_by')}</span>
|
||||
<select class="select select-sm select-bordered flex-1"
|
||||
@change=${onChange}>
|
||||
${sortOptions}
|
||||
</select>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get app config from the embedded window object.
|
||||
* @returns {Object} App configuration
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
html, litRender, nothing, t,
|
||||
getConfig, formatDateTime, formatDateTimeShort, formatRelativeTime,
|
||||
warningBadge,
|
||||
pagination, sortableTableHeader,
|
||||
pagination, sortableTableHeader, mobileSortSelect,
|
||||
renderFilterCard, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay,
|
||||
observerIcons, observerDetailRow, toggleObserverDetail, toggleCardObserverDetail
|
||||
} from '../components.js';
|
||||
@@ -239,6 +239,20 @@ ${displayContent}`, container);
|
||||
|
||||
renderPage(html`${filterCard}
|
||||
|
||||
${mobileSortSelect({
|
||||
currentSort: sort, currentOrder: order,
|
||||
navigate, basePath: '/advertisements',
|
||||
params: headerParams,
|
||||
options: [
|
||||
{ value: 'time:desc', label: t('advertisements.sort.newest') },
|
||||
{ value: 'time:asc', label: t('advertisements.sort.oldest') },
|
||||
{ value: 'node_name:asc', label: t('advertisements.sort.node_az') },
|
||||
{ value: 'node_name:desc', label: t('advertisements.sort.node_za') },
|
||||
{ value: 'public_key:asc', label: t('advertisements.sort.key_asc') },
|
||||
{ value: 'public_key:desc', label: t('advertisements.sort.key_desc') },
|
||||
],
|
||||
})}
|
||||
|
||||
<div class="lg:hidden space-y-3">
|
||||
${mobileCards}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
getConfig, formatDateTime, formatDateTimeShort, formatRelativeTime,
|
||||
getChannelLabelsMap, resolveChannelLabel,
|
||||
truncateKey, warningBadge,
|
||||
pagination, sortableTableHeader, timezoneIndicator,
|
||||
pagination, sortableTableHeader, mobileSortSelect, timezoneIndicator,
|
||||
renderFilterCard, autoSubmit, submitOnEnter,
|
||||
observerIcons, observerDetailRow, toggleObserverDetail, toggleCardObserverDetail
|
||||
} from '../components.js';
|
||||
@@ -386,6 +386,22 @@ ${displayContent}`, container);
|
||||
|
||||
renderPage(html`${filterCard}
|
||||
|
||||
${mobileSortSelect({
|
||||
currentSort: sort, currentOrder: order,
|
||||
navigate, basePath: '/messages',
|
||||
params: headerParams,
|
||||
options: [
|
||||
{ value: 'time:desc', label: t('messages.sort.newest') },
|
||||
{ value: 'time:asc', label: t('messages.sort.oldest') },
|
||||
{ value: 'type:asc', label: t('messages.sort.type_az') },
|
||||
{ value: 'type:desc', label: t('messages.sort.type_za') },
|
||||
{ value: 'from:asc', label: t('messages.sort.from_az') },
|
||||
{ value: 'from:desc', label: t('messages.sort.from_za') },
|
||||
{ value: 'message:asc', label: t('messages.sort.message_az') },
|
||||
{ value: 'message:desc', label: t('messages.sort.message_za') },
|
||||
],
|
||||
})}
|
||||
|
||||
<div class="lg:hidden space-y-3">
|
||||
${mobileCards}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
html, litRender, nothing,
|
||||
getConfig, formatDateTime, formatDateTimeShort,
|
||||
warningBadge,
|
||||
pagination, sortableTableHeader,
|
||||
pagination, sortableTableHeader, mobileSortSelect,
|
||||
renderFilterCard, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, t
|
||||
} from '../components.js';
|
||||
import { createAutoRefresh } from '../auto-refresh.js';
|
||||
@@ -16,8 +16,8 @@ export async function render(container, params, router) {
|
||||
const page = parseInt(query.page, 10) || 1;
|
||||
const limit = parseInt(query.limit, 10) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
const sort = query.sort || 'name';
|
||||
const order = query.order || 'asc';
|
||||
const sort = query.sort || 'last_seen';
|
||||
const order = query.order || 'desc';
|
||||
|
||||
const config = getConfig();
|
||||
const tz = config.timezone || '';
|
||||
@@ -186,6 +186,20 @@ ${displayContent}`, container);
|
||||
|
||||
renderPage(html`${filterCard}
|
||||
|
||||
${mobileSortSelect({
|
||||
currentSort: sort, currentOrder: order,
|
||||
navigate, basePath: '/nodes',
|
||||
params: headerParams,
|
||||
options: [
|
||||
{ value: 'last_seen:desc', label: t('nodes.sort.last_seen_newest') },
|
||||
{ value: 'last_seen:asc', label: t('nodes.sort.last_seen_oldest') },
|
||||
{ value: 'name:asc', label: t('nodes.sort.name_az') },
|
||||
{ value: 'name:desc', label: t('nodes.sort.name_za') },
|
||||
{ value: 'public_key:asc', label: t('nodes.sort.key_asc') },
|
||||
{ value: 'public_key:desc', label: t('nodes.sort.key_desc') },
|
||||
],
|
||||
})}
|
||||
|
||||
<div class="lg:hidden space-y-3">
|
||||
${mobileCards}
|
||||
</div>
|
||||
|
||||
@@ -105,7 +105,8 @@
|
||||
"unnamed": "Unnamed",
|
||||
"unnamed_node": "Unnamed Node",
|
||||
"validation_invalid_number": "Value must be a valid number",
|
||||
"validation_invalid_boolean": "Value must be true, false, yes, no, 1, or 0"
|
||||
"validation_invalid_boolean": "Value must be true, false, yes, no, 1, or 0",
|
||||
"sort_by": "Sort by"
|
||||
},
|
||||
"links": {
|
||||
"website": "Website",
|
||||
@@ -162,14 +163,41 @@
|
||||
"not_adopted": "This node has not been adopted by any operator.",
|
||||
"adopt_success": "Node adopted successfully",
|
||||
"release_success": "Node released successfully",
|
||||
"release_confirm": "Are you sure you want to release this node?"
|
||||
"release_confirm": "Are you sure you want to release this node?",
|
||||
"sort": {
|
||||
"last_seen_newest": "Last Seen (newest)",
|
||||
"last_seen_oldest": "Last Seen (oldest)",
|
||||
"name_az": "Name (A\u2013Z)",
|
||||
"name_za": "Name (Z\u2013A)",
|
||||
"key_asc": "Public Key (ascending)",
|
||||
"key_desc": "Public Key (descending)"
|
||||
}
|
||||
},
|
||||
"advertisements": {
|
||||
"sort": {
|
||||
"newest": "Time (newest)",
|
||||
"oldest": "Time (oldest)",
|
||||
"node_az": "Node (A\u2013Z)",
|
||||
"node_za": "Node (Z\u2013A)",
|
||||
"key_asc": "Public Key (ascending)",
|
||||
"key_desc": "Public Key (descending)"
|
||||
}
|
||||
},
|
||||
"advertisements": {},
|
||||
"messages": {
|
||||
"type_direct": "Direct",
|
||||
"type_channel": "Channel",
|
||||
"type_contact": "Contact",
|
||||
"type_public": "Public"
|
||||
"type_public": "Public",
|
||||
"sort": {
|
||||
"newest": "Time (newest)",
|
||||
"oldest": "Time (oldest)",
|
||||
"type_az": "Type (A\u2013Z)",
|
||||
"type_za": "Type (Z\u2013A)",
|
||||
"from_az": "From (A\u2013Z)",
|
||||
"from_za": "From (Z\u2013A)",
|
||||
"message_az": "Message (A\u2013Z)",
|
||||
"message_za": "Message (Z\u2013A)"
|
||||
}
|
||||
},
|
||||
"map": {
|
||||
"show_labels": "Show Labels",
|
||||
|
||||
@@ -469,35 +469,36 @@ class TestNodeTags:
|
||||
class TestNodeSort:
|
||||
"""Tests for node list sort parameters."""
|
||||
|
||||
def test_sort_by_name_default(self, client_no_auth, api_db_session):
|
||||
"""Default sort (no params) returns nodes alpha by display name."""
|
||||
from datetime import datetime, timezone
|
||||
def test_sort_by_last_seen_default(self, client_no_auth, api_db_session):
|
||||
"""Default sort (no params) returns nodes by last_seen descending."""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from meshcore_hub.common.models import Node
|
||||
|
||||
node_b = Node(
|
||||
public_key="bb" * 32,
|
||||
name="Bravo",
|
||||
adv_type="CLIENT",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
last_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
node_a = Node(
|
||||
public_key="aa" * 32,
|
||||
name="Alpha",
|
||||
adv_type="CLIENT",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
last_seen=datetime.now(timezone.utc),
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
)
|
||||
api_db_session.add_all([node_b, node_a])
|
||||
node_b = Node(
|
||||
public_key="bb" * 32,
|
||||
name="Bravo",
|
||||
adv_type="CLIENT",
|
||||
first_seen=now,
|
||||
last_seen=now + timedelta(hours=1),
|
||||
)
|
||||
api_db_session.add_all([node_a, node_b])
|
||||
api_db_session.commit()
|
||||
|
||||
response = client_no_auth.get("/api/v1/nodes")
|
||||
assert response.status_code == 200
|
||||
items = response.json()["items"]
|
||||
assert len(items) == 2
|
||||
assert items[0]["name"] == "Alpha"
|
||||
assert items[1]["name"] == "Bravo"
|
||||
assert items[0]["name"] == "Bravo"
|
||||
assert items[1]["name"] == "Alpha"
|
||||
|
||||
def test_sort_by_name_asc(self, client_no_auth, api_db_session):
|
||||
"""Explicit sort=name&order=asc."""
|
||||
@@ -641,30 +642,34 @@ class TestNodeSort:
|
||||
assert items[1]["name"] == "Alpha"
|
||||
|
||||
def test_sort_invalid_ignored(self, client_no_auth, api_db_session):
|
||||
"""Invalid sort value falls back to default (name alpha)."""
|
||||
from datetime import datetime, timezone
|
||||
"""Invalid sort value falls back to default (last_seen desc)."""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from meshcore_hub.common.models import Node
|
||||
|
||||
node_b = Node(
|
||||
public_key="bb" * 32,
|
||||
name="Bravo",
|
||||
adv_type="CLIENT",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
node_a = Node(
|
||||
public_key="aa" * 32,
|
||||
name="Alpha",
|
||||
adv_type="CLIENT",
|
||||
first_seen=datetime.now(timezone.utc),
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
)
|
||||
api_db_session.add_all([node_b, node_a])
|
||||
node_b = Node(
|
||||
public_key="bb" * 32,
|
||||
name="Bravo",
|
||||
adv_type="CLIENT",
|
||||
first_seen=now,
|
||||
last_seen=now + timedelta(hours=1),
|
||||
)
|
||||
api_db_session.add_all([node_a, node_b])
|
||||
api_db_session.commit()
|
||||
|
||||
response = client_no_auth.get("/api/v1/nodes?sort=invalid_column")
|
||||
assert response.status_code == 200
|
||||
items = response.json()["items"]
|
||||
assert items[0]["name"] == "Alpha"
|
||||
assert items[0]["name"] == "Bravo"
|
||||
assert items[1]["name"] == "Alpha"
|
||||
|
||||
def test_sort_nodes_with_null_name(self, client_no_auth, api_db_session):
|
||||
"""Nodes with name=NULL sort by public_key via COALESCE fallback."""
|
||||
|
||||
Reference in New Issue
Block a user