358 lines
13 KiB
Markdown
358 lines
13 KiB
Markdown
# Subscription Table Collapsible Groups Implementation Plan
|
|
|
|
## Objective
|
|
Add collapsible/expandable functionality to subscription groups, where:
|
|
1. Each WSI Pointer group starts collapsed, showing only a summary row
|
|
2. Clicking the summary row expands to show all subscription details for that WSI Pointer
|
|
3. Clicking again collapses the group back to the summary row
|
|
|
|
## Current Behavior
|
|
- All subscriptions are displayed in a flat list
|
|
- WSI Pointer value shown only on first row of each group
|
|
- No interaction or collapse functionality
|
|
|
|
## Desired Behavior
|
|
- **Collapsed state**: One row per WSI Pointer showing summary information
|
|
- **Expanded state**: Header row + detail rows for each subscription in that group
|
|
- **Toggle**: Click on header row to expand/collapse
|
|
- **Visual indicator**: Arrow or icon showing expand/collapse state
|
|
|
|
## Design Approach
|
|
|
|
### Option 1: Summary Row + Detail Rows (Recommended)
|
|
```
|
|
[▶] WSI Pointer: 0x12345678 | Subscriptions: 3 | Total Duration: 15m
|
|
(detail rows hidden)
|
|
|
|
When clicked:
|
|
[▼] WSI Pointer: 0x12345678 | Subscriptions: 3 | Total Duration: 15m
|
|
| sub-001 | 5m 30s | kinds:[1]
|
|
| sub-002 | 3m 15s | kinds:[3]
|
|
| sub-003 | 6m 15s | kinds:[1,3]
|
|
```
|
|
|
|
### Option 2: First Row as Header (Alternative)
|
|
```
|
|
[▶] 0x12345678 | sub-001 | 5m 30s | kinds:[1] (+ 2 more)
|
|
|
|
When clicked:
|
|
[▼] 0x12345678 | sub-001 | 5m 30s | kinds:[1]
|
|
| sub-002 | 3m 15s | kinds:[3]
|
|
| sub-003 | 6m 15s | kinds:[1,3]
|
|
```
|
|
|
|
**Recommendation**: Option 1 provides clearer UX and better summary information.
|
|
|
|
## Implementation Details
|
|
|
|
### Files to Modify
|
|
1. `api/index.js` - Function `populateSubscriptionDetailsTable()` (lines 4277-4384)
|
|
2. `api/index.css` - Add styles for collapsible rows
|
|
|
|
### Data Structure Changes
|
|
|
|
Need to group subscriptions by WSI Pointer first:
|
|
```javascript
|
|
// Group subscriptions by wsi_pointer
|
|
const groupedSubscriptions = {};
|
|
subscriptionsData.forEach(sub => {
|
|
const wsiKey = sub.wsi_pointer || 'N/A';
|
|
if (!groupedSubscriptions[wsiKey]) {
|
|
groupedSubscriptions[wsiKey] = [];
|
|
}
|
|
groupedSubscriptions[wsiKey].push(sub);
|
|
});
|
|
```
|
|
|
|
### HTML Structure
|
|
|
|
#### Summary Row (Header)
|
|
```html
|
|
<tr class="subscription-group-header" data-wsi-pointer="0x12345678" data-expanded="false">
|
|
<td colspan="4" style="cursor: pointer; user-select: none;">
|
|
<span class="expand-icon">▶</span>
|
|
<strong>WSI Pointer:</strong> 0x12345678
|
|
<span class="group-summary">
|
|
| Subscriptions: 3 | Oldest: 15m 30s
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
```
|
|
|
|
#### Detail Rows (Initially Hidden)
|
|
```html
|
|
<tr class="subscription-detail-row" data-wsi-group="0x12345678" style="display: none;">
|
|
<td style="padding-left: 30px;"><!-- Empty for WSI Pointer --></td>
|
|
<td>sub-001</td>
|
|
<td>5m 30s</td>
|
|
<td>kinds:[1]</td>
|
|
</tr>
|
|
```
|
|
|
|
### JavaScript Implementation
|
|
|
|
#### Modified populateSubscriptionDetailsTable Function
|
|
|
|
```javascript
|
|
function populateSubscriptionDetailsTable(subscriptionsData) {
|
|
const tableBody = document.getElementById('subscription-details-table-body');
|
|
if (!tableBody || !subscriptionsData || !Array.isArray(subscriptionsData)) return;
|
|
|
|
tableBody.innerHTML = '';
|
|
|
|
if (subscriptionsData.length === 0) {
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = '<td colspan="4" style="text-align: center; font-style: italic;">No active subscriptions</td>';
|
|
tableBody.appendChild(row);
|
|
return;
|
|
}
|
|
|
|
// Sort subscriptions by wsi_pointer to group them together
|
|
subscriptionsData.sort((a, b) => {
|
|
const wsiA = a.wsi_pointer || '';
|
|
const wsiB = b.wsi_pointer || '';
|
|
return wsiA.localeCompare(wsiB);
|
|
});
|
|
|
|
// Group subscriptions by wsi_pointer
|
|
const groupedSubscriptions = {};
|
|
subscriptionsData.forEach(sub => {
|
|
const wsiKey = sub.wsi_pointer || 'N/A';
|
|
if (!groupedSubscriptions[wsiKey]) {
|
|
groupedSubscriptions[wsiKey] = [];
|
|
}
|
|
groupedSubscriptions[wsiKey].push(sub);
|
|
});
|
|
|
|
// Create rows for each group
|
|
Object.entries(groupedSubscriptions).forEach(([wsiPointer, subscriptions]) => {
|
|
// Calculate group summary
|
|
const subCount = subscriptions.length;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const oldestDuration = Math.max(...subscriptions.map(s => now - s.created_at));
|
|
const oldestDurationStr = formatDuration(oldestDuration);
|
|
|
|
// Create header row (summary)
|
|
const headerRow = document.createElement('tr');
|
|
headerRow.className = 'subscription-group-header';
|
|
headerRow.setAttribute('data-wsi-pointer', wsiPointer);
|
|
headerRow.setAttribute('data-expanded', 'false');
|
|
headerRow.style.cursor = 'pointer';
|
|
headerRow.style.userSelect = 'none';
|
|
headerRow.style.backgroundColor = 'var(--hover-color, #f5f5f5)';
|
|
|
|
headerRow.innerHTML = `
|
|
<td colspan="4" style="padding: 8px;">
|
|
<span class="expand-icon" style="display: inline-block; width: 20px; transition: transform 0.2s;">▶</span>
|
|
<strong style="font-family: 'Courier New', monospace; font-size: 12px;">WSI: ${wsiPointer}</strong>
|
|
<span style="color: #666; margin-left: 15px;">
|
|
Subscriptions: ${subCount} | Oldest: ${oldestDurationStr}
|
|
</span>
|
|
</td>
|
|
`;
|
|
|
|
// Add click handler to toggle expansion
|
|
headerRow.addEventListener('click', () => toggleSubscriptionGroup(wsiPointer));
|
|
|
|
tableBody.appendChild(headerRow);
|
|
|
|
// Create detail rows (initially hidden)
|
|
subscriptions.forEach((subscription, index) => {
|
|
const detailRow = document.createElement('tr');
|
|
detailRow.className = 'subscription-detail-row';
|
|
detailRow.setAttribute('data-wsi-group', wsiPointer);
|
|
detailRow.style.display = 'none';
|
|
|
|
// Calculate duration
|
|
const duration = now - subscription.created_at;
|
|
const durationStr = formatDuration(duration);
|
|
|
|
// Format filters
|
|
let filtersDisplay = 'None';
|
|
if (subscription.filters && subscription.filters.length > 0) {
|
|
const filterDetails = [];
|
|
subscription.filters.forEach((filter) => {
|
|
const parts = [];
|
|
|
|
if (filter.kinds && Array.isArray(filter.kinds) && filter.kinds.length > 0) {
|
|
parts.push(`kinds:[${filter.kinds.join(',')}]`);
|
|
}
|
|
|
|
if (filter.authors && Array.isArray(filter.authors) && filter.authors.length > 0) {
|
|
const authorCount = filter.authors.length;
|
|
if (authorCount === 1) {
|
|
const shortPubkey = filter.authors[0].substring(0, 8) + '...';
|
|
parts.push(`authors:[${shortPubkey}]`);
|
|
} else {
|
|
parts.push(`authors:[${authorCount} pubkeys]`);
|
|
}
|
|
}
|
|
|
|
if (filter.ids && Array.isArray(filter.ids) && filter.ids.length > 0) {
|
|
const idCount = filter.ids.length;
|
|
parts.push(`ids:[${idCount} event${idCount > 1 ? 's' : ''}]`);
|
|
}
|
|
|
|
const timeParts = [];
|
|
if (filter.since && filter.since > 0) {
|
|
const sinceDate = new Date(filter.since * 1000).toLocaleString();
|
|
timeParts.push(`since:${sinceDate}`);
|
|
}
|
|
if (filter.until && filter.until > 0) {
|
|
const untilDate = new Date(filter.until * 1000).toLocaleString();
|
|
timeParts.push(`until:${untilDate}`);
|
|
}
|
|
if (timeParts.length > 0) {
|
|
parts.push(timeParts.join(', '));
|
|
}
|
|
|
|
if (filter.limit && filter.limit > 0) {
|
|
parts.push(`limit:${filter.limit}`);
|
|
}
|
|
|
|
if (filter.tag_filters && Array.isArray(filter.tag_filters) && filter.tag_filters.length > 0) {
|
|
parts.push(`tags:[${filter.tag_filters.length} filter${filter.tag_filters.length > 1 ? 's' : ''}]`);
|
|
}
|
|
|
|
if (parts.length > 0) {
|
|
filterDetails.push(parts.join(', '));
|
|
} else {
|
|
filterDetails.push('empty filter');
|
|
}
|
|
});
|
|
|
|
filtersDisplay = filterDetails.join(' | ');
|
|
}
|
|
|
|
detailRow.innerHTML = `
|
|
<td style="padding-left: 30px; font-family: 'Courier New', monospace; font-size: 11px; color: #999;">└─</td>
|
|
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${subscription.id || 'N/A'}</td>
|
|
<td>${durationStr}</td>
|
|
<td>${filtersDisplay}</td>
|
|
`;
|
|
|
|
tableBody.appendChild(detailRow);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Toggle function for expanding/collapsing groups
|
|
function toggleSubscriptionGroup(wsiPointer) {
|
|
const headerRow = document.querySelector(`.subscription-group-header[data-wsi-pointer="${wsiPointer}"]`);
|
|
const detailRows = document.querySelectorAll(`.subscription-detail-row[data-wsi-group="${wsiPointer}"]`);
|
|
const expandIcon = headerRow.querySelector('.expand-icon');
|
|
|
|
const isExpanded = headerRow.getAttribute('data-expanded') === 'true';
|
|
|
|
if (isExpanded) {
|
|
// Collapse
|
|
detailRows.forEach(row => row.style.display = 'none');
|
|
expandIcon.textContent = '▶';
|
|
expandIcon.style.transform = 'rotate(0deg)';
|
|
headerRow.setAttribute('data-expanded', 'false');
|
|
} else {
|
|
// Expand
|
|
detailRows.forEach(row => row.style.display = 'table-row');
|
|
expandIcon.textContent = '▼';
|
|
expandIcon.style.transform = 'rotate(90deg)';
|
|
headerRow.setAttribute('data-expanded', 'true');
|
|
}
|
|
}
|
|
```
|
|
|
|
### CSS Additions (api/index.css)
|
|
|
|
```css
|
|
/* Subscription group header styles */
|
|
.subscription-group-header {
|
|
background-color: var(--hover-color, #f5f5f5);
|
|
font-weight: 500;
|
|
transition: background-color 0.2s;
|
|
}
|
|
|
|
.subscription-group-header:hover {
|
|
background-color: var(--primary-color-light, #e8e8e8);
|
|
}
|
|
|
|
.expand-icon {
|
|
display: inline-block;
|
|
width: 20px;
|
|
transition: transform 0.2s ease;
|
|
font-size: 12px;
|
|
}
|
|
|
|
/* Detail row styles */
|
|
.subscription-detail-row {
|
|
background-color: var(--background-color, #ffffff);
|
|
}
|
|
|
|
.subscription-detail-row:hover {
|
|
background-color: var(--hover-color-light, #fafafa);
|
|
}
|
|
|
|
/* Dark mode support */
|
|
.dark-mode .subscription-group-header {
|
|
background-color: var(--hover-color-dark, #2a2a2a);
|
|
}
|
|
|
|
.dark-mode .subscription-group-header:hover {
|
|
background-color: var(--primary-color-dark, #333333);
|
|
}
|
|
|
|
.dark-mode .subscription-detail-row {
|
|
background-color: var(--background-color-dark, #1a1a1a);
|
|
}
|
|
|
|
.dark-mode .subscription-detail-row:hover {
|
|
background-color: var(--hover-color-dark, #252525);
|
|
}
|
|
```
|
|
|
|
## Features Included
|
|
|
|
1. ✅ **Collapsible groups**: Each WSI Pointer group can be collapsed/expanded
|
|
2. ✅ **Visual indicator**: Arrow icon (▶/▼) shows current state
|
|
3. ✅ **Summary information**: Shows subscription count and oldest duration
|
|
4. ✅ **Smooth transitions**: Icon rotation animation
|
|
5. ✅ **Hover effects**: Visual feedback on header rows
|
|
6. ✅ **Tree structure**: Detail rows indented with └─ character
|
|
7. ✅ **Dark mode support**: Proper styling for both themes
|
|
8. ✅ **Keyboard accessible**: Can be enhanced with keyboard navigation
|
|
|
|
## User Experience Flow
|
|
|
|
1. **Initial load**: All groups collapsed, showing summary rows only
|
|
2. **Click header**: Group expands, showing all subscription details
|
|
3. **Click again**: Group collapses back to summary
|
|
4. **Multiple groups**: Each group can be independently expanded/collapsed
|
|
5. **Visual feedback**: Hover effects and smooth animations
|
|
|
|
## Testing Checklist
|
|
|
|
1. ✅ Verify groups start collapsed by default
|
|
2. ✅ Verify clicking header expands group
|
|
3. ✅ Verify clicking again collapses group
|
|
4. ✅ Verify multiple groups can be expanded simultaneously
|
|
5. ✅ Verify summary information is accurate
|
|
6. ✅ Verify detail rows display correctly when expanded
|
|
7. ✅ Verify styling works in both light and dark modes
|
|
8. ✅ Verify no console errors
|
|
9. ✅ Verify performance with many subscriptions
|
|
|
|
## Optional Enhancements
|
|
|
|
1. **Expand/Collapse All**: Add buttons to expand or collapse all groups at once
|
|
2. **Remember State**: Store expansion state in localStorage
|
|
3. **Keyboard Navigation**: Add keyboard shortcuts (Space/Enter to toggle)
|
|
4. **Animation**: Add slide-down/up animation for detail rows
|
|
5. **Search/Filter**: Add ability to search within subscriptions
|
|
6. **Export**: Add ability to export subscription data
|
|
|
|
## Next Steps
|
|
|
|
1. Review this plan
|
|
2. Switch to Code mode
|
|
3. Implement the changes in `api/index.js`
|
|
4. Add CSS styles in `api/index.css`
|
|
5. Test the collapsible functionality
|
|
6. Verify all edge cases work correctly |