From 38fa0fe99da595d53d241fbb18ca53e65302e47a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Wed, 14 Jan 2026 17:10:06 -0600 Subject: [PATCH 1/9] Bug Fix: Fixed NumPy baseline detection in Docker entrypoint. Now calls `numpy.show_config()` directly with case-insensitive grep instead of incorrectly wrapping the output. --- CHANGELOG.md | 4 ++++ docker/entrypoint.sh | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5d42062..1aa7a3f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Fixed NumPy baseline detection in Docker entrypoint. Now calls `numpy.show_config()` directly with case-insensitive grep instead of incorrectly wrapping the output. + ## [0.17.0] - 2026-01-13 ### Added diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 1622097b..a50f2f49 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -30,7 +30,7 @@ echo_with_timestamp() { # --- NumPy version switching for legacy hardware --- if [ "$USE_LEGACY_NUMPY" = "true" ]; then # Check if NumPy was compiled with baseline support - if /dispatcharrpy/bin/python -c "import numpy; print(str(numpy.show_config()).lower())" 2>/dev/null | grep -q "baseline"; then + if /dispatcharrpy/bin/python -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline"; then echo_with_timestamp "🔧 Switching to legacy NumPy (no CPU baseline)..." /dispatcharrpy/bin/pip install --no-cache-dir --force-reinstall --no-deps /opt/numpy-*.whl echo_with_timestamp "✅ Legacy NumPy installed" From 54644df9a3f2b1edec12d2cfad86975e4078c409 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 15 Jan 2026 08:55:38 -0600 Subject: [PATCH 2/9] Test Fix: Fixed SettingsUtils frontend tests for new grouped settings architecture. Updated test suite to properly verify grouped JSON settings (stream_settings, dvr_settings, etc.) instead of individual CharField settings, including tests for type conversions, array-to-CSV transformations, and special handling of proxy_settings and network_access. Frontend tests GitHub workflow now uses Node.js 24 (matching Dockerfile) and runs on both `main` and `dev` branch pushes and pull requests for comprehensive CI coverage. --- .github/workflows/frontend-tests.yml | 10 +- CHANGELOG.md | 5 + .../src/utils/__tests__/dateTimeUtils.test.js | 1 + .../StreamConnectionCardUtils.test.js | 5 +- frontend/src/utils/networkUtils.js | 6 +- .../pages/__tests__/SettingsUtils.test.js | 675 +++++++----------- 6 files changed, 281 insertions(+), 421 deletions(-) diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index 828bdc43..b279a82a 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -2,9 +2,9 @@ name: Frontend Tests on: push: - branches: [ main ] + branches: [main, dev] pull_request: - branches: [ main ] + branches: [main, dev] jobs: test: @@ -21,15 +21,15 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '24' cache: 'npm' cache-dependency-path: './frontend/package-lock.json' - name: Install dependencies run: npm ci -# - name: Run linter -# run: npm run lint + # - name: Run linter + # run: npm run lint - name: Run tests run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aa7a3f2..610c2ee5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Frontend tests GitHub workflow now uses Node.js 24 (matching Dockerfile) and runs on both `main` and `dev` branch pushes and pull requests for comprehensive CI coverage. + ### Fixed - Fixed NumPy baseline detection in Docker entrypoint. Now calls `numpy.show_config()` directly with case-insensitive grep instead of incorrectly wrapping the output. +- Fixed SettingsUtils frontend tests for new grouped settings architecture. Updated test suite to properly verify grouped JSON settings (stream_settings, dvr_settings, etc.) instead of individual CharField settings, including tests for type conversions, array-to-CSV transformations, and special handling of proxy_settings and network_access. ## [0.17.0] - 2026-01-13 diff --git a/frontend/src/utils/__tests__/dateTimeUtils.test.js b/frontend/src/utils/__tests__/dateTimeUtils.test.js index 852501e9..54644dcd 100644 --- a/frontend/src/utils/__tests__/dateTimeUtils.test.js +++ b/frontend/src/utils/__tests__/dateTimeUtils.test.js @@ -293,6 +293,7 @@ describe('dateTimeUtils', () => { const converted = result.current.toUserTime(null); + expect(converted).toBeDefined(); expect(converted.isValid()).toBe(false); }); diff --git a/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js b/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js index f48f1c1c..92c028c9 100644 --- a/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js +++ b/frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js @@ -14,16 +14,15 @@ describe('StreamConnectionCardUtils', () => { describe('getBufferingSpeedThreshold', () => { it('should return parsed buffering_speed from proxy settings', () => { const proxySetting = { - value: JSON.stringify({ buffering_speed: 2.5 }) + value: { buffering_speed: 2.5 } }; expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(2.5); }); it('should return 1.0 for invalid JSON', () => { - const proxySetting = { value: 'invalid json' }; + const proxySetting = { value: { buffering_speed: 'invalid' } }; const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); expect(StreamConnectionCardUtils.getBufferingSpeedThreshold(proxySetting)).toBe(1.0); - expect(consoleSpy).toHaveBeenCalled(); consoleSpy.mockRestore(); }); diff --git a/frontend/src/utils/networkUtils.js b/frontend/src/utils/networkUtils.js index d8131229..8efd2254 100644 --- a/frontend/src/utils/networkUtils.js +++ b/frontend/src/utils/networkUtils.js @@ -1,7 +1,9 @@ -export const IPV4_CIDR_REGEX = /^([0-9]{1,3}\.){3}[0-9]{1,3}\/\d+$/; +// IPv4 CIDR regex - validates IP address and prefix length (0-32) +export const IPV4_CIDR_REGEX = /^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\/(3[0-2]|[12]?[0-9])$/; +// IPv6 CIDR regex - validates IPv6 address and prefix length (0-128) export const IPV6_CIDR_REGEX = - /(?:(?:(?:[A-F0-9]{1,4}:){6}|(?=(?:[A-F0-9]{0,4}:){0,6}(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![:.\w]))(([0-9A-F]{1,4}:){0,5}|:)((:[0-9A-F]{1,4}){1,5}:|:)|::(?:[A-F0-9]{1,4}:){5})(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)|(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}|(?=(?:[A-F0-9]{0,4}:){0,7}[A-F0-9]{0,4}(?![:.\w]))(([0-9A-F]{1,4}:){1,7}|:)((:[0-9A-F]{1,4}){1,7}|:)|(?:[A-F0-9]{1,4}:){7}:|:(:[A-F0-9]{1,4}){7})(?![:.\w])\/(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])/; + /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/; export function formatBytes(bytes) { if (bytes === 0) return '0 Bytes'; diff --git a/frontend/src/utils/pages/__tests__/SettingsUtils.test.js b/frontend/src/utils/pages/__tests__/SettingsUtils.test.js index 9bf20b13..1611c7d3 100644 --- a/frontend/src/utils/pages/__tests__/SettingsUtils.test.js +++ b/frontend/src/utils/pages/__tests__/SettingsUtils.test.js @@ -19,540 +19,393 @@ describe('SettingsUtils', () => { describe('checkSetting', () => { it('should call API checkSetting with values', async () => { const values = { key: 'test-setting', value: 'test-value' }; - await SettingsUtils.checkSetting(values); - expect(API.checkSetting).toHaveBeenCalledWith(values); expect(API.checkSetting).toHaveBeenCalledTimes(1); }); - - it('should return API response', async () => { - const values = { key: 'test-setting', value: 'test-value' }; - const mockResponse = { valid: true }; - - API.checkSetting.mockResolvedValue(mockResponse); - - const result = await SettingsUtils.checkSetting(values); - - expect(result).toEqual(mockResponse); - }); - - it('should propagate API errors', async () => { - const values = { key: 'test-setting', value: 'test-value' }; - const error = new Error('API error'); - - API.checkSetting.mockRejectedValue(error); - - await expect(SettingsUtils.checkSetting(values)).rejects.toThrow('API error'); - }); }); describe('updateSetting', () => { it('should call API updateSetting with values', async () => { const values = { id: 1, key: 'test-setting', value: 'new-value' }; - await SettingsUtils.updateSetting(values); - expect(API.updateSetting).toHaveBeenCalledWith(values); expect(API.updateSetting).toHaveBeenCalledTimes(1); }); - - it('should return API response', async () => { - const values = { id: 1, key: 'test-setting', value: 'new-value' }; - const mockResponse = { id: 1, value: 'new-value' }; - - API.updateSetting.mockResolvedValue(mockResponse); - - const result = await SettingsUtils.updateSetting(values); - - expect(result).toEqual(mockResponse); - }); - - it('should propagate API errors', async () => { - const values = { id: 1, key: 'test-setting', value: 'new-value' }; - const error = new Error('Update failed'); - - API.updateSetting.mockRejectedValue(error); - - await expect(SettingsUtils.updateSetting(values)).rejects.toThrow('Update failed'); - }); }); describe('createSetting', () => { it('should call API createSetting with values', async () => { const values = { key: 'new-setting', name: 'New Setting', value: 'value' }; - await SettingsUtils.createSetting(values); - expect(API.createSetting).toHaveBeenCalledWith(values); expect(API.createSetting).toHaveBeenCalledTimes(1); }); - - it('should return API response', async () => { - const values = { key: 'new-setting', name: 'New Setting', value: 'value' }; - const mockResponse = { id: 1, ...values }; - - API.createSetting.mockResolvedValue(mockResponse); - - const result = await SettingsUtils.createSetting(values); - - expect(result).toEqual(mockResponse); - }); - - it('should propagate API errors', async () => { - const values = { key: 'new-setting', name: 'New Setting', value: 'value' }; - const error = new Error('Create failed'); - - API.createSetting.mockRejectedValue(error); - - await expect(SettingsUtils.createSetting(values)).rejects.toThrow('Create failed'); - }); }); describe('rehashStreams', () => { it('should call API rehashStreams', async () => { await SettingsUtils.rehashStreams(); - expect(API.rehashStreams).toHaveBeenCalledWith(); expect(API.rehashStreams).toHaveBeenCalledTimes(1); }); - - it('should return API response', async () => { - const mockResponse = { success: true }; - - API.rehashStreams.mockResolvedValue(mockResponse); - - const result = await SettingsUtils.rehashStreams(); - - expect(result).toEqual(mockResponse); - }); - - it('should propagate API errors', async () => { - const error = new Error('Rehash failed'); - - API.rehashStreams.mockRejectedValue(error); - - await expect(SettingsUtils.rehashStreams()).rejects.toThrow('Rehash failed'); - }); }); describe('saveChangedSettings', () => { - it('should update existing settings', async () => { + it('should group stream settings correctly and update', async () => { const settings = { - 'setting-1': { id: 1, key: 'setting-1', value: 'old-value' } + stream_settings: { + id: 1, + key: 'stream_settings', + value: { + default_user_agent: 5, + m3u_hash_key: 'channel_name' + } + } }; const changedSettings = { - 'setting-1': 'new-value' + default_user_agent: 7, + preferred_region: 'UK' }; - API.updateSetting.mockResolvedValue({ id: 1, value: 'new-value' }); + API.updateSetting.mockResolvedValue({}); await SettingsUtils.saveChangedSettings(settings, changedSettings); expect(API.updateSetting).toHaveBeenCalledWith({ id: 1, - key: 'setting-1', - value: 'new-value' + key: 'stream_settings', + value: { + default_user_agent: 7, + m3u_hash_key: 'channel_name', + preferred_region: 'UK' + } }); }); - it('should create new settings when not in settings object', async () => { - const settings = {}; + it('should convert m3u_hash_key array to comma-separated string', async () => { + const settings = { + stream_settings: { + id: 1, + key: 'stream_settings', + value: {} + } + }; const changedSettings = { - 'new-setting': 'value' + m3u_hash_key: ['channel_name', 'channel_number'] }; - API.createSetting.mockResolvedValue({ id: 1, key: 'new-setting', value: 'value' }); + API.updateSetting.mockResolvedValue({}); await SettingsUtils.saveChangedSettings(settings, changedSettings); - expect(API.createSetting).toHaveBeenCalledWith({ - key: 'new-setting', - name: 'new setting', - value: 'value' + expect(API.updateSetting).toHaveBeenCalledWith({ + id: 1, + key: 'stream_settings', + value: { + m3u_hash_key: 'channel_name,channel_number' + } }); }); - it('should create new settings when existing has no id', async () => { + it('should convert ID fields to integers', async () => { const settings = { - 'setting-1': { key: 'setting-1', value: 'old-value' } + stream_settings: { + id: 1, + key: 'stream_settings', + value: {} + } }; const changedSettings = { - 'setting-1': 'new-value' + default_user_agent: '5', + default_stream_profile: '3' }; - API.createSetting.mockResolvedValue({ id: 1, key: 'setting-1', value: 'new-value' }); + API.updateSetting.mockResolvedValue({}); await SettingsUtils.saveChangedSettings(settings, changedSettings); - expect(API.createSetting).toHaveBeenCalledWith({ - key: 'setting-1', - name: 'setting 1', - value: 'new-value' + expect(API.updateSetting).toHaveBeenCalledWith({ + id: 1, + key: 'stream_settings', + value: { + default_user_agent: 5, + default_stream_profile: 3 + } }); }); - it('should replace hyphens with spaces in name', async () => { - const settings = {}; - const changedSettings = { - 'multi-word-setting': 'value' - }; - - API.createSetting.mockResolvedValue({ id: 1 }); - - await SettingsUtils.saveChangedSettings(settings, changedSettings); - - expect(API.createSetting).toHaveBeenCalledWith({ - key: 'multi-word-setting', - name: 'multi word setting', - value: 'value' - }); - }); - - it('should throw error when update fails', async () => { + it('should preserve boolean types', async () => { const settings = { - 'setting-1': { id: 1, key: 'setting-1', value: 'old-value' } + dvr_settings: { + id: 2, + key: 'dvr_settings', + value: {} + }, + stream_settings: { + id: 1, + key: 'stream_settings', + value: {} + } }; const changedSettings = { - 'setting-1': 'new-value' + comskip_enabled: true, + auto_import_mapped_files: false }; - API.updateSetting.mockResolvedValue(undefined); - - await expect( - SettingsUtils.saveChangedSettings(settings, changedSettings) - ).rejects.toThrow('Failed to update setting'); - }); - - it('should throw error when create fails', async () => { - const settings = {}; - const changedSettings = { - 'new-setting': 'value' - }; - - API.createSetting.mockResolvedValue(undefined); - - await expect( - SettingsUtils.saveChangedSettings(settings, changedSettings) - ).rejects.toThrow('Failed to create setting'); - }); - - it('should process multiple changed settings', async () => { - const settings = { - 'setting-1': { id: 1, key: 'setting-1', value: 'old-value-1' }, - 'setting-2': { id: 2, key: 'setting-2', value: 'old-value-2' } - }; - const changedSettings = { - 'setting-1': 'new-value-1', - 'setting-2': 'new-value-2', - 'setting-3': 'new-value-3' - }; - - API.updateSetting.mockResolvedValue({ success: true }); - API.createSetting.mockResolvedValue({ success: true }); + API.updateSetting.mockResolvedValue({}); await SettingsUtils.saveChangedSettings(settings, changedSettings); expect(API.updateSetting).toHaveBeenCalledTimes(2); - expect(API.createSetting).toHaveBeenCalledTimes(1); }); - it('should handle empty changedSettings', async () => { + it('should handle proxy_settings specially', async () => { const settings = { - 'setting-1': { id: 1, key: 'setting-1', value: 'value' } + proxy_settings: { + id: 5, + key: 'proxy_settings', + value: { + buffering_speed: 1.0 + } + } }; - const changedSettings = {}; + const changedSettings = { + proxy_settings: { + buffering_speed: 2.5, + buffering_timeout: 15 + } + }; + + API.updateSetting.mockResolvedValue({}); await SettingsUtils.saveChangedSettings(settings, changedSettings); - expect(API.updateSetting).not.toHaveBeenCalled(); - expect(API.createSetting).not.toHaveBeenCalled(); - }); - }); - - describe('getChangedSettings', () => { - it('should detect changed values', () => { - const values = { - 'setting-1': 'new-value' - }; - const settings = { - 'setting-1': { id: 1, key: 'setting-1', value: 'old-value' } - }; - - const result = SettingsUtils.getChangedSettings(values, settings); - - expect(result).toEqual({ - 'setting-1': 'new-value' + expect(API.updateSetting).toHaveBeenCalledWith({ + id: 5, + key: 'proxy_settings', + value: { + buffering_speed: 2.5, + buffering_timeout: 15 + } }); }); - it('should include new settings not in settings object', () => { - const values = { - 'new-setting': 'value' - }; + it('should create proxy_settings if it does not exist', async () => { const settings = {}; + const changedSettings = { + proxy_settings: { + buffering_speed: 2.5 + } + }; - const result = SettingsUtils.getChangedSettings(values, settings); + API.createSetting.mockResolvedValue({}); - expect(result).toEqual({ - 'new-setting': 'value' + await SettingsUtils.saveChangedSettings(settings, changedSettings); + + expect(API.createSetting).toHaveBeenCalledWith({ + key: 'proxy_settings', + name: 'Proxy Settings', + value: { + buffering_speed: 2.5 + } }); }); - it('should skip unchanged values', () => { - const values = { - 'setting-1': 'same-value' - }; + it('should handle network_access specially', async () => { const settings = { - 'setting-1': { id: 1, key: 'setting-1', value: 'same-value' } + network_access: { + id: 6, + key: 'network_access', + value: [] + } + }; + const changedSettings = { + network_access: ['192.168.1.0/24', '10.0.0.0/8'] }; - const result = SettingsUtils.getChangedSettings(values, settings); + API.updateSetting.mockResolvedValue({}); - expect(result).toEqual({}); - }); + await SettingsUtils.saveChangedSettings(settings, changedSettings); - it('should convert array values to comma-separated strings', () => { - const values = { - 'm3u-hash-key': ['key1', 'key2', 'key3'] - }; - const settings = { - 'm3u-hash-key': { id: 1, key: 'm3u-hash-key', value: 'old-value' } - }; - - const result = SettingsUtils.getChangedSettings(values, settings); - - expect(result).toEqual({ - 'm3u-hash-key': 'key1,key2,key3' + expect(API.updateSetting).toHaveBeenCalledWith({ + id: 6, + key: 'network_access', + value: ['192.168.1.0/24', '10.0.0.0/8'] }); }); - - it('should skip empty string values', () => { - const values = { - 'setting-1': '', - 'setting-2': 'value' - }; - const settings = {}; - - const result = SettingsUtils.getChangedSettings(values, settings); - - expect(result).toEqual({ - 'setting-2': 'value' - }); - }); - - it('should skip empty array values', () => { - const values = { - 'setting-1': [], - 'setting-2': ['value'] - }; - const settings = {}; - - const result = SettingsUtils.getChangedSettings(values, settings); - - expect(result).toEqual({ - 'setting-2': 'value' - }); - }); - - it('should convert non-string values to strings', () => { - const values = { - 'setting-1': 123, - 'setting-2': true, - 'setting-3': false - }; - const settings = {}; - - const result = SettingsUtils.getChangedSettings(values, settings); - - expect(result).toEqual({ - 'setting-1': '123', - 'setting-2': 'true', - 'setting-3': 'false' - }); - }); - - it('should compare string values correctly', () => { - const values = { - 'setting-1': 'value', - 'setting-2': 123 - }; - const settings = { - 'setting-1': { id: 1, key: 'setting-1', value: 'value' }, - 'setting-2': { id: 2, key: 'setting-2', value: 123 } - }; - - const result = SettingsUtils.getChangedSettings(values, settings); - - expect(result).toEqual({}); - }); }); describe('parseSettings', () => { - it('should convert string "true" to boolean true', () => { - const settings = { - 'setting-1': { id: 1, key: 'setting-1', value: 'true' } + it('should parse grouped settings correctly', () => { + const mockSettings = { + 'stream_settings': { + id: 1, + key: 'stream_settings', + value: { + default_user_agent: 5, + default_stream_profile: 3, + m3u_hash_key: 'channel_name,channel_number', + preferred_region: 'US', + auto_import_mapped_files: true + } + }, + 'dvr_settings': { + id: 2, + key: 'dvr_settings', + value: { + tv_template: '/media/tv/{show}/{season}/', + comskip_enabled: false, + pre_offset_minutes: 2, + post_offset_minutes: 5 + } + } }; - const result = SettingsUtils.parseSettings(settings); + const result = SettingsUtils.parseSettings(mockSettings); - expect(result).toEqual({ - 'setting-1': true + // Check stream settings + expect(result.default_user_agent).toBe('5'); + expect(result.default_stream_profile).toBe('3'); + expect(result.m3u_hash_key).toEqual(['channel_name', 'channel_number']); + expect(result.preferred_region).toBe('US'); + expect(result.auto_import_mapped_files).toBe(true); + + // Check DVR settings + expect(result.tv_template).toBe('/media/tv/{show}/{season}/'); + expect(result.comskip_enabled).toBe(false); + expect(result.pre_offset_minutes).toBe(2); + expect(result.post_offset_minutes).toBe(5); + }); + + it('should handle empty m3u_hash_key', () => { + const mockSettings = { + 'stream_settings': { + id: 1, + key: 'stream_settings', + value: { + m3u_hash_key: '' + } + } + }; + + const result = SettingsUtils.parseSettings(mockSettings); + expect(result.m3u_hash_key).toEqual([]); + }); + + it('should handle proxy_settings', () => { + const mockSettings = { + 'proxy_settings': { + id: 5, + key: 'proxy_settings', + value: { + buffering_speed: 2.5, + buffering_timeout: 15 + } + } + }; + + const result = SettingsUtils.parseSettings(mockSettings); + expect(result.proxy_settings).toEqual({ + buffering_speed: 2.5, + buffering_timeout: 15 }); }); - it('should convert string "false" to boolean false', () => { - const settings = { - 'setting-1': { id: 1, key: 'setting-1', value: 'false' } + it('should handle network_access', () => { + const mockSettings = { + 'network_access': { + id: 6, + key: 'network_access', + value: ['192.168.1.0/24', '10.0.0.0/8'] + } }; - const result = SettingsUtils.parseSettings(settings); + const result = SettingsUtils.parseSettings(mockSettings); + expect(result.network_access).toEqual(['192.168.1.0/24', '10.0.0.0/8']); + }); + }); - expect(result).toEqual({ - 'setting-1': false + describe('getChangedSettings', () => { + it('should detect changes in primitive values', () => { + const values = { + time_zone: 'America/New_York', + max_system_events: 2000, + comskip_enabled: true + }; + const settings = { + time_zone: { value: 'UTC' }, + max_system_events: { value: 1000 }, + comskip_enabled: { value: false } + }; + + const changes = SettingsUtils.getChangedSettings(values, settings); + + expect(changes).toEqual({ + time_zone: 'America/New_York', + max_system_events: 2000, + comskip_enabled: true }); }); - it('should parse m3u-hash-key as array', () => { + it('should not detect unchanged values', () => { + const values = { + time_zone: 'UTC', + max_system_events: 1000 + }; const settings = { - 'm3u-hash-key': { id: 1, key: 'm3u-hash-key', value: 'key1,key2,key3' } + time_zone: { value: 'UTC' }, + max_system_events: { value: 1000 } }; - const result = SettingsUtils.parseSettings(settings); + const changes = SettingsUtils.getChangedSettings(values, settings); + expect(changes).toEqual({}); + }); - expect(result).toEqual({ - 'm3u-hash-key': ['key1', 'key2', 'key3'] + it('should preserve type of numeric values', () => { + const values = { + max_system_events: 2000 + }; + const settings = { + max_system_events: { value: 1000 } + }; + + const changes = SettingsUtils.getChangedSettings(values, settings); + expect(typeof changes.max_system_events).toBe('number'); + expect(changes.max_system_events).toBe(2000); + }); + + it('should detect changes in array values', () => { + const values = { + m3u_hash_key: ['channel_name', 'channel_number'] + }; + const settings = { + m3u_hash_key: { value: 'channel_name' } + }; + + const changes = SettingsUtils.getChangedSettings(values, settings); + // Arrays are converted to comma-separated strings internally + expect(changes).toEqual({ + m3u_hash_key: 'channel_name,channel_number' }); }); - it('should filter empty strings from m3u-hash-key array', () => { + it('should skip proxy_settings and network_access', () => { + const values = { + time_zone: 'America/New_York', + proxy_settings: { + buffering_speed: 2.5 + }, + network_access: ['192.168.1.0/24'] + }; const settings = { - 'm3u-hash-key': { id: 1, key: 'm3u-hash-key', value: 'key1,,key2,' } + time_zone: { value: 'UTC' } }; - const result = SettingsUtils.parseSettings(settings); - - expect(result).toEqual({ - 'm3u-hash-key': ['key1', 'key2'] - }); - }); - - it('should return empty array for empty m3u-hash-key', () => { - const settings = { - 'm3u-hash-key': { id: 1, key: 'm3u-hash-key', value: '' } - }; - - const result = SettingsUtils.parseSettings(settings); - - expect(result).toEqual({ - 'm3u-hash-key': [] - }); - }); - - it('should return empty array for null m3u-hash-key', () => { - const settings = { - 'm3u-hash-key': { id: 1, key: 'm3u-hash-key', value: null } - }; - - const result = SettingsUtils.parseSettings(settings); - - expect(result).toEqual({ - 'm3u-hash-key': [] - }); - }); - - it('should parse dvr-pre-offset-minutes as integer', () => { - const settings = { - 'dvr-pre-offset-minutes': { id: 1, key: 'dvr-pre-offset-minutes', value: '5' } - }; - - const result = SettingsUtils.parseSettings(settings); - - expect(result).toEqual({ - 'dvr-pre-offset-minutes': 5 - }); - }); - - it('should parse dvr-post-offset-minutes as integer', () => { - const settings = { - 'dvr-post-offset-minutes': { id: 1, key: 'dvr-post-offset-minutes', value: '10' } - }; - - const result = SettingsUtils.parseSettings(settings); - - expect(result).toEqual({ - 'dvr-post-offset-minutes': 10 - }); - }); - - it('should default offset minutes to 0 for empty string', () => { - const settings = { - 'dvr-pre-offset-minutes': { id: 1, key: 'dvr-pre-offset-minutes', value: '' }, - 'dvr-post-offset-minutes': { id: 2, key: 'dvr-post-offset-minutes', value: '' } - }; - - const result = SettingsUtils.parseSettings(settings); - - expect(result).toEqual({ - 'dvr-pre-offset-minutes': 0, - 'dvr-post-offset-minutes': 0 - }); - }); - - it('should default offset minutes to 0 for NaN', () => { - const settings = { - 'dvr-pre-offset-minutes': { id: 1, key: 'dvr-pre-offset-minutes', value: 'invalid' }, - 'dvr-post-offset-minutes': { id: 2, key: 'dvr-post-offset-minutes', value: 'abc' } - }; - - const result = SettingsUtils.parseSettings(settings); - - expect(result).toEqual({ - 'dvr-pre-offset-minutes': 0, - 'dvr-post-offset-minutes': 0 - }); - }); - - it('should keep other values unchanged', () => { - const settings = { - 'setting-1': { id: 1, key: 'setting-1', value: 'test-value' }, - 'setting-2': { id: 2, key: 'setting-2', value: 123 } - }; - - const result = SettingsUtils.parseSettings(settings); - - expect(result).toEqual({ - 'setting-1': 'test-value', - 'setting-2': 123 - }); - }); - - it('should handle empty settings object', () => { - const result = SettingsUtils.parseSettings({}); - - expect(result).toEqual({}); - }); - - it('should process multiple settings with mixed types', () => { - const settings = { - 'enabled': { id: 1, key: 'enabled', value: 'true' }, - 'disabled': { id: 2, key: 'disabled', value: 'false' }, - 'm3u-hash-key': { id: 3, key: 'm3u-hash-key', value: 'key1,key2' }, - 'dvr-pre-offset-minutes': { id: 4, key: 'dvr-pre-offset-minutes', value: '5' }, - 'dvr-post-offset-minutes': { id: 5, key: 'dvr-post-offset-minutes', value: '10' }, - 'other-setting': { id: 6, key: 'other-setting', value: 'value' } - }; - - const result = SettingsUtils.parseSettings(settings); - - expect(result).toEqual({ - 'enabled': true, - 'disabled': false, - 'm3u-hash-key': ['key1', 'key2'], - 'dvr-pre-offset-minutes': 5, - 'dvr-post-offset-minutes': 10, - 'other-setting': 'value' - }); + const changes = SettingsUtils.getChangedSettings(values, settings); + expect(changes.proxy_settings).toBeUndefined(); + expect(changes.network_access).toBeUndefined(); + expect(changes.time_zone).toBe('America/New_York'); }); }); }); From 10169b96c094b8c1cba499fbda015470882dc649 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 15 Jan 2026 13:40:13 -0600 Subject: [PATCH 3/9] feat: Implement table header pin toggle and refactor table preferences management (Closes #663) - Added functionality to pin/unpin table headers, maintaining visibility while scrolling. This feature is accessible via the channel table menu and UI Settings page, with persistence across sessions. - Refactored table preferences management by migrating `table-size` preference to a centralized `useTablePreferences` hook, enhancing maintainability and consistency across table components. --- CHANGELOG.md | 7 ++ .../src/components/backups/BackupManager.jsx | 5 +- .../components/cards/StreamConnectionCard.jsx | 3 +- .../forms/settings/UiSettingsForm.jsx | 23 +++- .../src/components/tables/ChannelsTable.jsx | 14 ++- .../ChannelsTable/ChannelTableHeader.jsx | 23 ++++ .../tables/CustomTable/CustomTable.jsx | 5 +- .../tables/CustomTable/CustomTableHeader.jsx | 20 ++- .../components/tables/CustomTable/index.jsx | 21 +++- .../src/components/tables/StreamsTable.jsx | 8 +- frontend/src/hooks/useTablePreferences.jsx | 117 ++++++++++++++++++ 11 files changed, 217 insertions(+), 29 deletions(-) create mode 100644 frontend/src/hooks/useTablePreferences.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 610c2ee5..5282ff54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Table header pin toggle: Pin/unpin table headers to keep them visible while scrolling. Toggle available in channel table menu and UI Settings page. Setting persists across sessions and applies to all tables. (Closes #663) + ### Changed +- Table preferences (header pin and table size) now managed together with centralized state management and localStorage persistence. + - Frontend tests GitHub workflow now uses Node.js 24 (matching Dockerfile) and runs on both `main` and `dev` branch pushes and pull requests for comprehensive CI coverage. +- Table preferences architecture refactored: Migrated `table-size` preference from individual `useLocalStorage` calls to centralized `useTablePreferences` hook. All table components now read preferences from the table instance (`table.tableSize`, `table.headerPinned`) instead of calling hooks directly, improving maintainability and providing consistent API across all tables. ### Fixed diff --git a/frontend/src/components/backups/BackupManager.jsx b/frontend/src/components/backups/BackupManager.jsx index dc130254..0723dcf7 100644 --- a/frontend/src/components/backups/BackupManager.jsx +++ b/frontend/src/components/backups/BackupManager.jsx @@ -236,7 +236,6 @@ export default function BackupManager() { // Read user's preferences from settings const [timeFormat] = useLocalStorage('time-format', '12h'); const [dateFormatSetting] = useLocalStorage('date-format', 'mdy'); - const [tableSize] = useLocalStorage('table-size', 'default'); const [userTimezone] = useLocalStorage('time-zone', getDefaultTimeZone()); const is12Hour = timeFormat === '12h'; @@ -309,10 +308,10 @@ export default function BackupManager() { { id: 'actions', header: 'Actions', - size: tableSize === 'compact' ? 75 : 100, + size: 100, }, ], - [tableSize] + [] ); const renderHeaderCell = (header) => { diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx index 62d6e62f..a00f3664 100644 --- a/frontend/src/components/cards/StreamConnectionCard.jsx +++ b/frontend/src/components/cards/StreamConnectionCard.jsx @@ -71,7 +71,6 @@ const StreamConnectionCard = ({ // Get Date-format from localStorage const [dateFormatSetting] = useLocalStorage('date-format', 'mdy'); const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM'; - const [tableSize] = useLocalStorage('table-size', 'default'); // Create a map of M3U account IDs to names for quick lookup const m3uAccountsMap = useMemo(() => { @@ -296,7 +295,7 @@ const StreamConnectionCard = ({ { id: 'actions', header: 'Actions', - size: tableSize == 'compact' ? 75 : 100, + size: 100, }, ], [] diff --git a/frontend/src/components/forms/settings/UiSettingsForm.jsx b/frontend/src/components/forms/settings/UiSettingsForm.jsx index dc123916..68977b5b 100644 --- a/frontend/src/components/forms/settings/UiSettingsForm.jsx +++ b/frontend/src/components/forms/settings/UiSettingsForm.jsx @@ -1,18 +1,18 @@ import useSettingsStore from '../../../store/settings.jsx'; import useLocalStorage from '../../../hooks/useLocalStorage.jsx'; +import useTablePreferences from '../../../hooks/useTablePreferences.jsx'; import { buildTimeZoneOptions, getDefaultTimeZone, } from '../../../utils/dateTimeUtils.js'; import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { showNotification } from '../../../utils/notificationUtils.js'; -import { Select } from '@mantine/core'; +import { Select, Switch, Stack } from '@mantine/core'; import { saveTimeZoneSetting } from '../../../utils/forms/settings/UiSettingsFormUtils.js'; const UiSettingsForm = React.memo(() => { const settings = useSettingsStore((s) => s.settings); - const [tableSize, setTableSize] = useLocalStorage('table-size', 'default'); const [timeFormat, setTimeFormat] = useLocalStorage('time-format', '12h'); const [dateFormat, setDateFormat] = useLocalStorage('date-format', 'mdy'); const [timeZone, setTimeZone] = useLocalStorage( @@ -20,6 +20,10 @@ const UiSettingsForm = React.memo(() => { getDefaultTimeZone() ); + // Use shared table preferences hook + const { headerPinned, setHeaderPinned, tableSize, setTableSize } = + useTablePreferences(); + const timeZoneOptions = useMemo( () => buildTimeZoneOptions(timeZone), [timeZone] @@ -74,11 +78,14 @@ const UiSettingsForm = React.memo(() => { persistTimeZoneSetting(value); } break; + case 'header-pinned': + setHeaderPinned(value); + break; } }; return ( - <> + { onChange={(val) => onUISettingsChange('time-zone', val)} data={timeZoneOptions} /> - + ); }); diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index dc82c131..b986e70e 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -114,6 +114,7 @@ const ChannelRowActions = React.memo( ({ theme, row, + table, editChannel, deleteChannel, handleWatchStream, @@ -123,7 +124,6 @@ const ChannelRowActions = React.memo( // Extract the channel ID once to ensure consistency const channelId = row.original.id; const channelUuid = row.original.uuid; - const [tableSize, _] = useLocalStorage('table-size', 'default'); const authUser = useAuthStore((s) => s.user); @@ -149,6 +149,7 @@ const ChannelRowActions = React.memo( createRecording(row.original); }, [channelId]); + const tableSize = table?.tableSize ?? 'default'; const iconSize = tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md'; @@ -272,7 +273,6 @@ const ChannelsTable = ({ onReady }) => { // store/settings const env_mode = useSettingsStore((s) => s.environment.env_mode); const showVideo = useVideoStore((s) => s.showVideo); - const [tableSize, _] = useLocalStorage('table-size', 'default'); // store/warnings const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); @@ -429,9 +429,10 @@ const ChannelsTable = ({ onReady }) => { setIsLoading(false); hasFetchedData.current = true; - setTablePrefs({ + setTablePrefs((prev) => ({ + ...prev, pageSize: pagination.pageSize, - }); + })); setAllRowIds(ids); // Signal ready after first successful data fetch AND EPG data is loaded @@ -949,13 +950,14 @@ const ChannelsTable = ({ onReady }) => { }, { id: 'actions', - size: tableSize == 'compact' ? 75 : 100, + size: 100, enableResizing: false, header: '', - cell: ({ row }) => ( + cell: ({ row, table }) => ( s.user); const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); const suppressWarning = useWarningsStore((s) => s.suppressWarning); + + const headerPinned = table?.headerPinned ?? false; + const setHeaderPinned = table?.setHeaderPinned || (() => {}); const closeAssignChannelNumbersModal = () => { setAssignNumbersModalOpen(false); }; @@ -229,6 +235,10 @@ const ChannelTableHeader = ({ setShowOnlyStreamlessChannels(!showOnlyStreamlessChannels); }; + const toggleHeaderPinned = () => { + setHeaderPinned(!headerPinned); + }; + return ( @@ -346,6 +356,19 @@ const ChannelTableHeader = ({ + : + } + onClick={toggleHeaderPinned} + > + + {headerPinned ? 'Unpin Headers' : 'Pin Headers'} + + + + + } disabled={ diff --git a/frontend/src/components/tables/CustomTable/CustomTable.jsx b/frontend/src/components/tables/CustomTable/CustomTable.jsx index 90407d49..d6315818 100644 --- a/frontend/src/components/tables/CustomTable/CustomTable.jsx +++ b/frontend/src/components/tables/CustomTable/CustomTable.jsx @@ -4,10 +4,9 @@ import { useCallback, useState, useRef, useMemo } from 'react'; import { flexRender } from '@tanstack/react-table'; import table from '../../../helpers/table'; import CustomTableBody from './CustomTableBody'; -import useLocalStorage from '../../../hooks/useLocalStorage'; const CustomTable = ({ table }) => { - const [tableSize, _] = useLocalStorage('table-size', 'default'); + const tableSize = table?.tableSize ?? 'default'; // Get column sizing state for dependency tracking const columnSizing = table.getState().columnSizing; @@ -34,7 +33,6 @@ const CustomTable = ({ table }) => { minWidth: `${minTableWidth}px`, display: 'flex', flexDirection: 'column', - overflow: 'hidden', }} > { } selectedTableIds={table.selectedTableIds} tableCellProps={table.tableCellProps} + headerPinned={table.headerPinned} /> { const renderHeaderCell = (header) => { if (headerCellRenderFns[header.id]) { @@ -59,15 +60,22 @@ const CustomTableHeader = ({ return width; }, [headerGroups]); + // Memoize the style object to ensure it updates when headerPinned changes + const headerStyle = useMemo( + () => ({ + position: headerPinned ? 'sticky' : 'relative', + top: headerPinned ? 0 : 'auto', + backgroundColor: '#3E3E45', + zIndex: headerPinned ? 10 : 1, + }), + [headerPinned] + ); + return ( {getHeaderGroups().map((headerGroup) => ( { if (e.key === 'Shift') { @@ -244,8 +249,22 @@ const useTable = ({ expandedRowRenderer, setSelectedTableIds, isShiftKeyDown, // Include shift key state in the table instance + headerPinned, + setHeaderPinned, + tableSize, + setTableSize, }), - [selectedTableIdsSet, expandedRowIds, allRowIds, isShiftKeyDown] + [ + selectedTableIdsSet, + expandedRowIds, + allRowIds, + isShiftKeyDown, + options, + headerPinned, + setHeaderPinned, + tableSize, + setTableSize, + ] ); return { diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 21b13baf..72d50d49 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -68,8 +68,9 @@ const StreamRowActions = ({ handleWatchStream, selectedChannelIds, createChannelFromStream, + table, }) => { - const [tableSize, _] = useLocalStorage('table-size', 'default'); + const tableSize = table?.tableSize ?? 'default'; const channelSelectionStreams = useChannelsTableStore( (state) => state.channels.find((chan) => chan.id === selectedChannelIds[0])?.streams @@ -268,7 +269,6 @@ const StreamsTable = ({ onReady }) => { const selectedProfileId = useChannelsStore((s) => s.selectedProfileId); const env_mode = useSettingsStore((s) => s.environment.env_mode); const showVideo = useVideoStore((s) => s.showVideo); - const [tableSize, _] = useLocalStorage('table-size', 'default'); // Warnings store for "remember choice" functionality const suppressWarning = useWarningsStore((s) => s.suppressWarning); @@ -286,7 +286,7 @@ const StreamsTable = ({ onReady }) => { () => [ { id: 'actions', - size: columnSizing.actions || (tableSize == 'compact' ? 60 : 80), + size: columnSizing.actions || 75, }, { id: 'select', @@ -354,7 +354,7 @@ const StreamsTable = ({ onReady }) => { ), }, ], - [channelGroups, playlists, columnSizing, tableSize] + [channelGroups, playlists, columnSizing] ); /** diff --git a/frontend/src/hooks/useTablePreferences.jsx b/frontend/src/hooks/useTablePreferences.jsx new file mode 100644 index 00000000..218a2e33 --- /dev/null +++ b/frontend/src/hooks/useTablePreferences.jsx @@ -0,0 +1,117 @@ +import { useState, useEffect, useCallback } from 'react'; + +const useTablePreferences = () => { + // Initialize all preferences from localStorage + const [headerPinned, setHeaderPinnedState] = useState(() => { + try { + const prefs = localStorage.getItem('table-preferences'); + if (prefs) { + const parsed = JSON.parse(prefs); + return parsed.headerPinned ?? false; + } + } catch (e) { + console.error('Error reading headerPinned from localStorage:', e); + } + return false; + }); + + const [tableSize, setTableSizeState] = useState(() => { + try { + // Check new location first + const prefs = localStorage.getItem('table-preferences'); + if (prefs) { + const parsed = JSON.parse(prefs); + if (parsed.tableSize) { + return parsed.tableSize; + } + } + // Fallback to old location for migration + const oldSize = localStorage.getItem('table-size'); + if (oldSize) { + return JSON.parse(oldSize); + } + } catch (e) { + console.error('Error reading tableSize from localStorage:', e); + } + return 'default'; + }); + + // Listen for changes from other components + useEffect(() => { + const handleCustomEvent = (e) => { + if ( + e.detail.headerPinned !== undefined && + e.detail.headerPinned !== headerPinned + ) { + setHeaderPinnedState(e.detail.headerPinned); + } + if ( + e.detail.tableSize !== undefined && + e.detail.tableSize !== tableSize + ) { + setTableSizeState(e.detail.tableSize); + } + }; + + window.addEventListener('table-preferences-changed', handleCustomEvent); + return () => + window.removeEventListener( + 'table-preferences-changed', + handleCustomEvent + ); + }, [headerPinned, tableSize]); + + // Function to update headerPinned and persist to localStorage + const setHeaderPinned = useCallback((value) => { + setHeaderPinnedState(value); + + try { + // Read current prefs, update headerPinned, and save back + let prefs = {}; + const stored = localStorage.getItem('table-preferences'); + if (stored) { + prefs = JSON.parse(stored); + } + prefs.headerPinned = value; + localStorage.setItem('table-preferences', JSON.stringify(prefs)); + + // Dispatch custom event for same-page sync + window.dispatchEvent( + new CustomEvent('table-preferences-changed', { + detail: { headerPinned: value }, + }) + ); + } catch (e) { + console.error('Error saving headerPinned to localStorage:', e); + } + }, []); + + // Function to update tableSize and persist to localStorage + const setTableSize = useCallback((value) => { + setTableSizeState(value); + + try { + // Read current prefs, update tableSize, and save back + let prefs = {}; + const stored = localStorage.getItem('table-preferences'); + if (stored) { + prefs = JSON.parse(stored); + } + prefs.tableSize = value; + localStorage.setItem('table-preferences', JSON.stringify(prefs)); + + // Dispatch custom event for same-page sync + window.dispatchEvent( + new CustomEvent('table-preferences-changed', { + detail: { tableSize: value }, + }) + ); + } catch (e) { + console.error('Error saving tableSize to localStorage:', e); + } + }, []); + + return { headerPinned, setHeaderPinned, tableSize, setTableSize }; +}; + +export default useTablePreferences; From 0f29cc6e66e1576dec5341a2ea012388b86a22f8 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 15 Jan 2026 13:52:33 -0600 Subject: [PATCH 4/9] fix: Update timezone settings structure in UiSettingsFormUtils tests --- frontend/src/utils/dateTimeUtils.js | 2 +- .../__tests__/UiSettingsFormUtils.test.js | 77 ++++++++++--------- 2 files changed, 42 insertions(+), 37 deletions(-) diff --git a/frontend/src/utils/dateTimeUtils.js b/frontend/src/utils/dateTimeUtils.js index 53f9912c..6d90d42a 100644 --- a/frontend/src/utils/dateTimeUtils.js +++ b/frontend/src/utils/dateTimeUtils.js @@ -80,7 +80,7 @@ export const useTimeHelpers = () => { const toUserTime = useCallback( (value) => { - if (!value) return dayjs.invalid(); + if (!value) return dayjs(null); try { return initializeTime(value).tz(timeZone); } catch (error) { diff --git a/frontend/src/utils/forms/settings/__tests__/UiSettingsFormUtils.test.js b/frontend/src/utils/forms/settings/__tests__/UiSettingsFormUtils.test.js index c5471edc..add9dc3a 100644 --- a/frontend/src/utils/forms/settings/__tests__/UiSettingsFormUtils.test.js +++ b/frontend/src/utils/forms/settings/__tests__/UiSettingsFormUtils.test.js @@ -16,11 +16,11 @@ describe('UiSettingsFormUtils', () => { it('should update existing setting when id is present', async () => { const tzValue = 'America/New_York'; const settings = { - 'system-time-zone': { + 'system_settings': { id: 123, - key: 'system-time-zone', - name: 'System Time Zone', - value: 'UTC' + key: 'system_settings', + name: 'System Settings', + value: { time_zone: 'UTC' } } }; @@ -29,9 +29,9 @@ describe('UiSettingsFormUtils', () => { expect(SettingsUtils.updateSetting).toHaveBeenCalledTimes(1); expect(SettingsUtils.updateSetting).toHaveBeenCalledWith({ id: 123, - key: 'system-time-zone', - name: 'System Time Zone', - value: 'America/New_York' + key: 'system_settings', + name: 'System Settings', + value: { time_zone: 'America/New_York' } }); expect(SettingsUtils.createSetting).not.toHaveBeenCalled(); }); @@ -39,10 +39,10 @@ describe('UiSettingsFormUtils', () => { it('should create new setting when existing setting has no id', async () => { const tzValue = 'Europe/London'; const settings = { - 'system-time-zone': { - key: 'system-time-zone', - name: 'System Time Zone', - value: 'UTC' + 'system_settings': { + key: 'system_settings', + name: 'System Settings', + value: { time_zone: 'UTC' } } }; @@ -50,14 +50,14 @@ describe('UiSettingsFormUtils', () => { expect(SettingsUtils.createSetting).toHaveBeenCalledTimes(1); expect(SettingsUtils.createSetting).toHaveBeenCalledWith({ - key: 'system-time-zone', - name: 'System Time Zone', - value: 'Europe/London' + key: 'system_settings', + name: 'System Settings', + value: { time_zone: 'Europe/London' } }); expect(SettingsUtils.updateSetting).not.toHaveBeenCalled(); }); - it('should create new setting when system-time-zone does not exist', async () => { + it('should create new setting when system_settings does not exist', async () => { const tzValue = 'Asia/Tokyo'; const settings = {}; @@ -65,26 +65,26 @@ describe('UiSettingsFormUtils', () => { expect(SettingsUtils.createSetting).toHaveBeenCalledTimes(1); expect(SettingsUtils.createSetting).toHaveBeenCalledWith({ - key: 'system-time-zone', - name: 'System Time Zone', - value: 'Asia/Tokyo' + key: 'system_settings', + name: 'System Settings', + value: { time_zone: 'Asia/Tokyo' } }); expect(SettingsUtils.updateSetting).not.toHaveBeenCalled(); }); - it('should create new setting when system-time-zone is null', async () => { + it('should create new setting when system_settings is null', async () => { const tzValue = 'Pacific/Auckland'; const settings = { - 'system-time-zone': null + 'system_settings': null }; await UiSettingsFormUtils.saveTimeZoneSetting(tzValue, settings); expect(SettingsUtils.createSetting).toHaveBeenCalledTimes(1); expect(SettingsUtils.createSetting).toHaveBeenCalledWith({ - key: 'system-time-zone', - name: 'System Time Zone', - value: 'Pacific/Auckland' + key: 'system_settings', + name: 'System Settings', + value: { time_zone: 'Pacific/Auckland' } }); expect(SettingsUtils.updateSetting).not.toHaveBeenCalled(); }); @@ -92,10 +92,10 @@ describe('UiSettingsFormUtils', () => { it('should create new setting when id is undefined', async () => { const tzValue = 'America/Los_Angeles'; const settings = { - 'system-time-zone': { + 'system_settings': { id: undefined, - key: 'system-time-zone', - value: 'UTC' + key: 'system_settings', + value: { time_zone: 'UTC' } } }; @@ -108,11 +108,11 @@ describe('UiSettingsFormUtils', () => { it('should preserve existing properties when updating', async () => { const tzValue = 'UTC'; const settings = { - 'system-time-zone': { + 'system_settings': { id: 456, - key: 'system-time-zone', - name: 'System Time Zone', - value: 'America/New_York', + key: 'system_settings', + name: 'System Settings', + value: { time_zone: 'America/New_York', some_other_setting: 'value' }, extraProp: 'should be preserved' } }; @@ -121,9 +121,9 @@ describe('UiSettingsFormUtils', () => { expect(SettingsUtils.updateSetting).toHaveBeenCalledWith({ id: 456, - key: 'system-time-zone', - name: 'System Time Zone', - value: 'UTC', + key: 'system_settings', + name: 'System Settings', + value: { time_zone: 'UTC', some_other_setting: 'value' }, extraProp: 'should be preserved' }); }); @@ -131,8 +131,11 @@ describe('UiSettingsFormUtils', () => { it('should handle empty string timezone value', async () => { const tzValue = ''; const settings = { - 'system-time-zone': { - id: 789 + 'system_settings': { + id: 789, + key: 'system_settings', + name: 'System Settings', + value: { time_zone: 'America/New_York' } } }; @@ -140,7 +143,9 @@ describe('UiSettingsFormUtils', () => { expect(SettingsUtils.updateSetting).toHaveBeenCalledWith({ id: 789, - value: '' + key: 'system_settings', + name: 'System Settings', + value: { time_zone: '' } }); }); }); From f0267508ffef64b180276ad384332bd1055d54e0 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 15 Jan 2026 16:26:06 -0600 Subject: [PATCH 5/9] Enhancement: Cascading filters for streams table: Improved filter usability with hierarchical M3U and Group dropdowns. M3U acts as the parent filter showing only active/enabled accounts, while Group options dynamically update to display only groups available in the selected M3U(s). Only enabled M3U's are displayed. (Closes #647) --- CHANGELOG.md | 1 + apps/channels/api_views.py | 69 ++++++++- frontend/src/api.js | 12 ++ .../src/components/tables/StreamsTable.jsx | 141 ++++++++++++++++-- frontend/src/index.css | 20 ++- 5 files changed, 227 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5282ff54..c834174d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Table header pin toggle: Pin/unpin table headers to keep them visible while scrolling. Toggle available in channel table menu and UI Settings page. Setting persists across sessions and applies to all tables. (Closes #663) +- Cascading filters for streams table: Improved filter usability with hierarchical M3U and Group dropdowns. M3U acts as the parent filter showing only active/enabled accounts, while Group options dynamically update to display only groups available in the selected M3U(s). Only enabled M3U's are displayed. (Closes #647) ### Changed diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index fcf50f49..8ea5db8a 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -96,7 +96,7 @@ class StreamFilter(django_filters.FilterSet): channel_group_name = OrInFilter( field_name="channel_group__name", lookup_expr="icontains" ) - m3u_account = django_filters.NumberFilter(field_name="m3u_account__id") + m3u_account = django_filters.BaseInFilter(field_name="m3u_account__id") m3u_account_name = django_filters.CharFilter( field_name="m3u_account__name", lookup_expr="icontains" ) @@ -194,6 +194,73 @@ class StreamViewSet(viewsets.ModelViewSet): # Return the response with the list of unique group names return Response(list(group_names)) + @action(detail=False, methods=["get"], url_path="filter-options") + def get_filter_options(self, request, *args, **kwargs): + """ + Get available filter options based on current filter state. + Uses a hierarchical approach: M3U is the parent filter, Group filters based on M3U. + """ + # For group options: we need to bypass the channel_group custom queryset filtering + # Store original request params + original_params = request.query_params + + # Create modified params without channel_group for getting group options + params_without_group = request.GET.copy() + params_without_group.pop('channel_group', None) + params_without_group.pop('channel_group_name', None) + + # Temporarily modify request to exclude channel_group + request._request.GET = params_without_group + base_queryset_for_groups = self.get_queryset() + + # Apply filterset (which will apply M3U filters) + group_filterset = self.filterset_class( + params_without_group, + queryset=base_queryset_for_groups + ) + group_queryset = group_filterset.qs + + group_names = ( + group_queryset.exclude(channel_group__isnull=True) + .order_by("channel_group__name") + .values_list("channel_group__name", flat=True) + .distinct() + ) + + # For M3U options: show ALL M3Us (don't filter by anything except name search) + params_for_m3u = request.GET.copy() + params_for_m3u.pop('m3u_account', None) + params_for_m3u.pop('channel_group', None) + params_for_m3u.pop('channel_group_name', None) + + # Temporarily modify request to exclude filters for M3U options + request._request.GET = params_for_m3u + base_queryset_for_m3u = self.get_queryset() + + m3u_filterset = self.filterset_class( + params_for_m3u, + queryset=base_queryset_for_m3u + ) + m3u_queryset = m3u_filterset.qs + + m3u_accounts = ( + m3u_queryset.exclude(m3u_account__isnull=True) + .order_by("m3u_account__name") + .values("m3u_account__id", "m3u_account__name") + .distinct() + ) + + # Restore original params + request._request.GET = original_params + + return Response({ + "groups": list(group_names), + "m3u_accounts": [ + {"id": m3u["m3u_account__id"], "name": m3u["m3u_account__name"]} + for m3u in m3u_accounts + ] + }) + @swagger_auto_schema( method="post", operation_description="Retrieve streams by a list of IDs using POST to avoid URL length limitations", diff --git a/frontend/src/api.js b/frontend/src/api.js index c33ff1ee..ab22848c 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -727,6 +727,18 @@ export default class API { } } + static async getStreamFilterOptions(params) { + try { + const response = await request( + `${host}/api/channels/streams/filter-options/?${params.toString()}` + ); + + return response; + } catch (e) { + errorNotification('Failed to retrieve filter options', e); + } + } + static async addStream(values) { try { const response = await request(`${host}/api/channels/streams/`, { diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 72d50d49..02dae3d5 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -186,6 +186,7 @@ const StreamsTable = ({ onReady }) => { const [stream, setStream] = useState(null); const [modalOpen, setModalOpen] = useState(false); const [groupOptions, setGroupOptions] = useState([]); + const [m3uOptions, setM3uOptions] = useState([]); const [initialDataCount, setInitialDataCount] = useState(null); const [data, setData] = useState([]); // Holds fetched data @@ -371,14 +372,14 @@ const StreamsTable = ({ onReady }) => { const handleGroupChange = (value) => { setFilters((prev) => ({ ...prev, - channel_group: value ? value : '', + channel_group: value && value.length > 0 ? value.join(',') : '', })); }; const handleM3UChange = (value) => { setFilters((prev) => ({ ...prev, - m3u_account: value ? value : '', + m3u_account: value && value.length > 0 ? value.join(',') : '', })); }; @@ -419,16 +420,24 @@ const StreamsTable = ({ onReady }) => { }); try { - const [result, ids, groups] = await Promise.all([ + const [result, ids, filterOptions] = await Promise.all([ API.queryStreams(params), API.getAllStreamIds(params), - API.getStreamGroups(), + API.getStreamFilterOptions(params), ]); setAllRowIds(ids); setData(result.results); setPageCount(Math.ceil(result.count / pagination.pageSize)); - setGroupOptions(groups); + + // Set filtered options based on current filters + setGroupOptions(filterOptions.groups); + setM3uOptions( + filterOptions.m3u_accounts.map((m3u) => ({ + label: m3u.name, + value: `${m3u.id}`, + })) + ); // Calculate the starting and ending item indexes const startItem = pagination.pageIndex * pagination.pageSize + 1; // +1 to start from 1, not 0 @@ -844,7 +853,10 @@ const StreamsTable = ({ onReady }) => { ); - case 'group': + case 'group': { + const selectedGroups = filters.channel_group + ? filters.channel_group.split(',').filter(Boolean) + : []; return ( { nothingFoundMessage="No options" onClick={handleSelectClick} onChange={handleGroupChange} + value={selectedGroups} data={groupOptions} variant="unstyled" className="table-input-header custom-multiselect" clearable + valueComponent={({ value }) => { + const index = selectedGroups.indexOf(value); + if (index === 0) { + return ( + + + {value} + + {selectedGroups.length > 1 && ( + + +{selectedGroups.length - 1} + + )} + + ); + } + return null; + }} style={{ flex: 1, minWidth: 0 }} rightSectionPointerEvents="auto" rightSection={React.createElement(sortingIcon, { @@ -871,11 +916,15 @@ const StreamsTable = ({ onReady }) => { /> ); + } - case 'm3u': + case 'm3u': { + const selectedM3Us = filters.m3u_account + ? filters.m3u_account.split(',').filter(Boolean) + : []; return ( -