feat: Add Ombi request filtering and search
Build and Push Docker Image / build (push) Successful in 1m29s
Docs Check / Markdown lint (push) Successful in 1m51s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 2m3s
CI / Security audit (push) Successful in 2m54s
CI / Swagger Validation & Coverage (push) Successful in 3m6s
Docs Check / Mermaid diagram parse check (push) Successful in 3m13s
CI / Tests & coverage (push) Successful in 3m31s

- Add request filters UI (type, status, sort, search)
- Implement dual-layer filtering (server + client)
- Add ombiFilters utility for consistent filtering logic
- Persist filter preferences in localStorage
- Add SSE support for real-time Ombi request updates
- Add webhook endpoints for Ombi integration
- Update OpenAPI spec for new endpoints
- Add unit tests for filter logic and UI
- Add integration tests for Ombi routes
This commit is contained in:
2026-05-22 12:31:31 +01:00
parent dbf45ec31d
commit d3d085d614
17 changed files with 1695 additions and 83 deletions
+253
View File
@@ -0,0 +1,253 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* @vitest-environment jsdom
* Tests for client/src/ui/requestFilters.js
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { state } from '../../../client/src/state.js';
import { initRequestFilters } from '../../../client/src/ui/requestFilters.js';
import { renderRequests } from '../../../client/src/ui/requests.js';
// Mock renderRequests to verify re-render triggers
vi.mock('../../../client/src/ui/requests.js', () => ({
renderRequests: vi.fn()
}));
// Mock localStorage
const localStorageMock = (() => {
let store = {};
return {
getItem: (key) => store[key] || null,
setItem: (key, value) => { store[key] = value; },
removeItem: (key) => { delete store[key]; },
clear: () => { store = {}; }
};
})();
Object.defineProperty(window, 'localStorage', {
value: localStorageMock
});
function setupDOM() {
document.body.innerHTML = `
<div class="requests-controls">
<div class="request-filter" id="request-type-filter">
<button class="request-filter-btn" id="request-type-filter-btn" type="button">
<span id="request-type-selected-text">All</span>
<span class="dropdown-arrow">▼</span>
</button>
<div class="request-filter-dropdown" id="request-type-filter-dropdown">
<div class="request-filter-dropdown-header">
<button id="request-type-select-all" type="button">Select All</button>
<button id="request-type-deselect-all" type="button">Deselect All</button>
</div>
<div class="request-filter-options" id="request-type-options">
<div class="request-filter-option" data-value="movie">
<input type="checkbox" id="request-type-movie" class="request-filter-checkbox" checked>
<label for="request-type-movie">Movies</label>
</div>
<div class="request-filter-option" data-value="tv">
<input type="checkbox" id="request-type-tv" class="request-filter-checkbox" checked>
<label for="request-type-tv">TV Shows</label>
</div>
</div>
</div>
</div>
<div class="request-filter" id="request-status-filter">
<button class="request-filter-btn" id="request-status-filter-btn" type="button">
<span id="request-status-selected-text">All</span>
<span class="dropdown-arrow">▼</span>
</button>
<div class="request-filter-dropdown" id="request-status-filter-dropdown">
<div class="request-filter-dropdown-header">
<button id="request-status-select-all" type="button">Select All</button>
<button id="request-status-deselect-all" type="button">Deselect All</button>
</div>
<div class="request-filter-options" id="request-status-options">
<div class="request-filter-option" data-value="pending">
<input type="checkbox" id="request-status-pending" class="request-filter-checkbox">
<label for="request-status-pending">Pending</label>
</div>
<div class="request-filter-option" data-value="approved">
<input type="checkbox" id="request-status-approved" class="request-filter-checkbox">
<label for="request-status-approved">Approved</label>
</div>
<div class="request-filter-option" data-value="available">
<input type="checkbox" id="request-status-available" class="request-filter-checkbox">
<label for="request-status-available">Available</label>
</div>
<div class="request-filter-option" data-value="denied">
<input type="checkbox" id="request-status-denied" class="request-filter-checkbox">
<label for="request-status-denied">Denied</label>
</div>
</div>
</div>
</div>
<div class="request-sort">
<select id="request-sort-select" class="request-sort-select">
<option value="requestedDate_desc">Newest to oldest</option>
<option value="requestedDate_asc">Oldest to newest</option>
<option value="title_asc">AZ</option>
<option value="title_desc">ZA</option>
</select>
</div>
<div class="request-search">
<input type="text" id="request-search-input" class="request-search-input" placeholder="Search by title...">
</div>
</div>
`;
}
describe('initRequestFilters', () => {
beforeEach(() => {
localStorageMock.clear();
state.selectedRequestTypes = ['movie', 'tv'];
state.selectedRequestStatuses = [];
state.requestSortMode = 'requestedDate_desc';
state.requestSearchQuery = '';
vi.clearAllMocks();
setupDOM();
initRequestFilters();
});
afterEach(() => {
document.body.innerHTML = '';
});
it('restores saved type selections from localStorage', () => {
localStorageMock.setItem('sofarr-request-types', JSON.stringify(['tv']));
state.selectedRequestTypes = ['tv'];
setupDOM();
initRequestFilters();
const movieCb = document.getElementById('request-type-movie');
const tvCb = document.getElementById('request-type-tv');
expect(movieCb.checked).toBe(false);
expect(tvCb.checked).toBe(true);
});
it('restores saved status selections from localStorage', () => {
localStorageMock.setItem('sofarr-request-statuses', JSON.stringify(['pending', 'approved']));
state.selectedRequestStatuses = ['pending', 'approved'];
setupDOM();
initRequestFilters();
expect(document.getElementById('request-status-pending').checked).toBe(true);
expect(document.getElementById('request-status-approved').checked).toBe(true);
expect(document.getElementById('request-status-available').checked).toBe(false);
});
it('restores saved sort mode', () => {
localStorageMock.setItem('sofarr-request-sort', 'title_asc');
state.requestSortMode = 'title_asc';
setupDOM();
initRequestFilters();
expect(document.getElementById('request-sort-select').value).toBe('title_asc');
});
it('restores saved search query', () => {
localStorageMock.setItem('sofarr-request-search', 'batman');
state.requestSearchQuery = 'batman';
setupDOM();
initRequestFilters();
expect(document.getElementById('request-search-input').value).toBe('batman');
});
it('toggles type checkbox and updates state', () => {
const movieCb = document.getElementById('request-type-movie');
movieCb.click();
expect(state.selectedRequestTypes).toEqual(['tv']);
expect(localStorageMock.getItem('sofarr-request-types')).toBe(JSON.stringify(['tv']));
expect(renderRequests).toHaveBeenCalled();
});
it('toggles status checkbox and updates state', () => {
const pendingCb = document.getElementById('request-status-pending');
pendingCb.click();
expect(state.selectedRequestStatuses).toEqual(['pending']);
expect(localStorageMock.getItem('sofarr-request-statuses')).toBe(JSON.stringify(['pending']));
expect(renderRequests).toHaveBeenCalled();
});
it('select all sets all types', () => {
state.selectedRequestTypes = [];
setupDOM();
initRequestFilters();
document.getElementById('request-type-select-all').click();
expect(state.selectedRequestTypes).toEqual(['movie', 'tv']);
expect(renderRequests).toHaveBeenCalled();
});
it('deselect all clears all types', () => {
document.getElementById('request-type-deselect-all').click();
expect(state.selectedRequestTypes).toEqual([]);
expect(renderRequests).toHaveBeenCalled();
});
it('select all sets all statuses', () => {
document.getElementById('request-status-select-all').click();
expect(state.selectedRequestStatuses).toEqual(['pending', 'approved', 'available', 'denied']);
expect(renderRequests).toHaveBeenCalled();
});
it('deselect all clears all statuses', () => {
state.selectedRequestStatuses = ['pending', 'approved'];
setupDOM();
initRequestFilters();
document.getElementById('request-status-deselect-all').click();
expect(state.selectedRequestStatuses).toEqual([]);
expect(renderRequests).toHaveBeenCalled();
});
it('changing sort select updates state', () => {
const select = document.getElementById('request-sort-select');
select.value = 'title_asc';
select.dispatchEvent(new Event('change'));
expect(state.requestSortMode).toBe('title_asc');
expect(localStorageMock.getItem('sofarr-request-sort')).toBe('title_asc');
expect(renderRequests).toHaveBeenCalled();
});
it('typing in search input updates state after debounce', async () => {
const input = document.getElementById('request-search-input');
input.value = 'bat';
input.dispatchEvent(new Event('input'));
// State shouldn't update immediately due to debounce
expect(state.requestSearchQuery).toBe('');
expect(renderRequests).not.toHaveBeenCalled();
// Wait for debounce
await new Promise(r => setTimeout(r, 250));
expect(state.requestSearchQuery).toBe('bat');
expect(localStorageMock.getItem('sofarr-request-search')).toBe('bat');
expect(renderRequests).toHaveBeenCalled();
});
it('clicking outside closes dropdowns', () => {
const typeDropdown = document.getElementById('request-type-filter-dropdown');
const typeBtn = document.getElementById('request-type-filter-btn');
typeBtn.click();
expect(typeDropdown.classList.contains('open')).toBe(true);
document.body.click();
expect(typeDropdown.classList.contains('open')).toBe(false);
});
});