diff --git a/frontend/jest.config.js b/frontend/jest.config.js
index 3b28ba37..8892a752 100644
--- a/frontend/jest.config.js
+++ b/frontend/jest.config.js
@@ -5,5 +5,5 @@ module.exports = {
'^.+\\.tsx?$': 'ts-jest',
'^.+\\.jsx?$': 'babel-jest',
},
- collectCoverageFrom: ['src/**'],
+ collectCoverageFrom: ['src/**', '!src/static/lib/**'],
};
diff --git a/frontend/tests/utils/hooks/useBulkActions.test.tsx b/frontend/tests/utils/hooks/useBulkActions.test.tsx
new file mode 100644
index 00000000..00fca6c5
--- /dev/null
+++ b/frontend/tests/utils/hooks/useBulkActions.test.tsx
@@ -0,0 +1,749 @@
+import React from 'react';
+import { render, fireEvent, act } from '@testing-library/react';
+import { useBulkActions } from '../../../src/static/js/utils/hooks/useBulkActions';
+
+// Mock translateString to return the input for easier assertions
+jest.mock('../../../src/static/js/utils/helpers', () => ({
+ translateString: (s: string) => s,
+}));
+
+// Component that exposes hook state/handlers to DOM for testing
+function HookConsumer() {
+ const hook = useBulkActions();
+
+ return (
+
+
{Array.from(hook.selectedMedia).length}
+
{hook.availableMediaIds.length}
+
{String(hook.showConfirmModal)}
+
{hook.confirmMessage}
+
{hook.listKey}
+
{hook.notificationMessage}
+
{String(hook.showNotification)}
+
+ {/* @todo: It doesn't used */}
+ {/*
{hook.notificationType}
*/}
+
+
{String(hook.showPermissionModal)}
+
{hook.permissionType || ''}
+
{String(hook.showPlaylistModal)}
+
{String(hook.showChangeOwnerModal)}
+
{String(hook.showPublishStateModal)}
+
{String(hook.showCategoryModal)}
+
{String(hook.showTagModal)}
+
+
+ );
+}
+
+describe('useBulkActions', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ document.cookie.split(';').forEach((c) => {
+ document.cookie = c.replace(/^ +/, '').replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/');
+ });
+
+ global.fetch = jest.fn();
+
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ describe('Utility Functions', () => {
+ test('getCsrfToken reads csrftoken from cookies', () => {
+ document.cookie = 'csrftoken=abc123';
+ const { getByTestId } = render();
+ expect(getByTestId('csrf').textContent).toBe('abc123');
+ });
+
+ test('getCsrfToken returns null when csrftoken is not present', () => {
+ // No cookie set, should return null
+ const { getByTestId } = render();
+ expect(getByTestId('csrf').textContent).toBe('null');
+ });
+
+ test('getCsrfToken returns null when document.cookie is empty', () => {
+ // Even if we try to set empty cookie, it should return null if no csrftoken
+ const { getByTestId } = render();
+ expect(getByTestId('csrf').textContent).toBe('null');
+ });
+ });
+
+ describe('Selection Management', () => {
+ test('handleMediaSelection toggles selected media', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ expect(getByTestId('selected-count').textContent).toBe('1');
+
+ fireEvent.click(getByTestId('btn-handle-media-deselect'));
+ expect(getByTestId('selected-count').textContent).toBe('0');
+ });
+
+ test('handleItemsUpdate extracts ids correctly from items with different id types', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-items-update'));
+ expect(getByTestId('available-count').textContent).toBe('3');
+ });
+
+ test('handleSelectAll selects all available items', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-items-update'));
+ fireEvent.click(getByTestId('btn-select-all'));
+ expect(getByTestId('selected-count').textContent).toBe('3');
+ });
+
+ test('handleDeselectAll deselects all items', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-items-update'));
+ fireEvent.click(getByTestId('btn-select-all'));
+ fireEvent.click(getByTestId('btn-deselect-all'));
+ expect(getByTestId('selected-count').textContent).toBe('0');
+ });
+
+ test('clearSelection clears all selected media', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ expect(getByTestId('selected-count').textContent).toBe('1');
+
+ fireEvent.click(getByTestId('btn-clear-selection'));
+ expect(getByTestId('selected-count').textContent).toBe('0');
+ });
+
+ test('clearSelectionAndRefresh clears selection and increments listKey', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-items-update'));
+ fireEvent.click(getByTestId('btn-select-all'));
+ expect(getByTestId('list-key').textContent).toBe('0');
+
+ fireEvent.click(getByTestId('btn-clear-refresh'));
+ expect(getByTestId('selected-count').textContent).toBe('0');
+ expect(getByTestId('list-key').textContent).toBe('1');
+ });
+ });
+
+ describe('Bulk Actions - Modal Opening', () => {
+ test('handleBulkAction does nothing when no selection', () => {
+ const { getByTestId } = render();
+ fireEvent.click(getByTestId('btn-bulk-delete'));
+ expect(getByTestId('show-confirm').textContent).toBe('false');
+ });
+
+ test('handleBulkAction opens confirm modal for delete, enable/disable comments and download, copy', () => {
+ const { getByTestId } = render();
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+
+ fireEvent.click(getByTestId('btn-bulk-delete'));
+ expect(getByTestId('show-confirm').textContent).toBe('true');
+
+ fireEvent.click(getByTestId('btn-bulk-enable-comments'));
+ expect(getByTestId('show-confirm').textContent).toBe('true');
+
+ fireEvent.click(getByTestId('btn-bulk-disable-comments'));
+ expect(getByTestId('show-confirm').textContent).toBe('true');
+
+ fireEvent.click(getByTestId('btn-bulk-enable-download'));
+ expect(getByTestId('show-confirm').textContent).toBe('true');
+
+ fireEvent.click(getByTestId('btn-bulk-disable-download'));
+ expect(getByTestId('show-confirm').textContent).toBe('true');
+
+ fireEvent.click(getByTestId('btn-bulk-copy'));
+ expect(getByTestId('show-confirm').textContent).toBe('true');
+ });
+
+ test('handleBulkAction opens permission modals with correct types', () => {
+ const { getByTestId } = render();
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+
+ fireEvent.click(getByTestId('btn-bulk-perm-viewer'));
+ expect(getByTestId('show-permission').textContent).toBe('true');
+ expect(getByTestId('permission-type').textContent).toBe('viewer');
+
+ fireEvent.click(getByTestId('btn-bulk-perm-editor'));
+ expect(getByTestId('permission-type').textContent).toBe('editor');
+
+ fireEvent.click(getByTestId('btn-bulk-perm-owner'));
+ expect(getByTestId('permission-type').textContent).toBe('owner');
+ });
+
+ test('handleBulkAction opens other modals', () => {
+ const { getByTestId } = render();
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+
+ fireEvent.click(getByTestId('btn-bulk-playlist'));
+ expect(getByTestId('show-playlist').textContent).toBe('true');
+
+ fireEvent.click(getByTestId('btn-bulk-change-owner'));
+ expect(getByTestId('show-change-owner').textContent).toBe('true');
+
+ fireEvent.click(getByTestId('btn-bulk-publish'));
+ expect(getByTestId('show-publish-state').textContent).toBe('true');
+
+ fireEvent.click(getByTestId('btn-bulk-category'));
+ expect(getByTestId('show-category').textContent).toBe('true');
+
+ fireEvent.click(getByTestId('btn-bulk-tag'));
+ expect(getByTestId('show-tag').textContent).toBe('true');
+ });
+
+ test('handleBulkAction with unknown action does nothing', () => {
+ const { getByTestId } = render();
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-unknown'));
+ expect(getByTestId('show-confirm').textContent).toBe('false');
+ expect(getByTestId('show-permission').textContent).toBe('false');
+ });
+ });
+
+ describe('Confirm Modal Handlers', () => {
+ test('handleConfirmCancel closes confirm modal and resets state', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-delete'));
+ expect(getByTestId('show-confirm').textContent).toBe('true');
+
+ fireEvent.click(getByTestId('btn-confirm-cancel'));
+ expect(getByTestId('show-confirm').textContent).toBe('false');
+ expect(getByTestId('confirm-message').textContent).toBe('');
+ });
+ });
+
+ describe('Delete Media Execution', () => {
+ test('executeDeleteMedia success with notification', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-delete'));
+
+ await act(async () => {
+ fireEvent.click(getByTestId('btn-confirm-proceed'));
+ await Promise.resolve();
+ });
+
+ expect(getByTestId('notification-message').textContent).toContain('The media was deleted successfully');
+ expect(getByTestId('show-notification').textContent).toBe('true');
+
+ act(() => {
+ jest.advanceTimersByTime(5000);
+ });
+ expect(getByTestId('show-notification').textContent).toBe('false');
+ });
+
+ test('executeDeleteMedia handles response.ok = false', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-delete'));
+
+ await act(async () => {
+ fireEvent.click(getByTestId('btn-confirm-proceed'));
+ await Promise.resolve();
+ });
+
+ expect(getByTestId('notification-message').textContent).toContain('Failed to delete media');
+ });
+
+ test('executeDeleteMedia handles fetch rejection exception', async () => {
+ (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-delete'));
+
+ await act(async () => {
+ fireEvent.click(getByTestId('btn-confirm-proceed'));
+ await Promise.resolve();
+ });
+
+ expect(getByTestId('notification-message').textContent).toContain('Failed to delete media');
+ expect(getByTestId('selected-count').textContent).toBe('0');
+ });
+ });
+
+ describe('Comments Management Execution', () => {
+ test('executeEnableComments success', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-enable-comments'));
+
+ await act(async () => {
+ fireEvent.click(getByTestId('btn-confirm-proceed'));
+ await Promise.resolve();
+ });
+
+ expect(getByTestId('notification-message').textContent).toContain('Successfully Enabled comments');
+ });
+
+ test('executeEnableComments handles response.ok = false', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-enable-comments'));
+
+ await act(async () => {
+ fireEvent.click(getByTestId('btn-confirm-proceed'));
+ await Promise.resolve();
+ });
+
+ expect(getByTestId('notification-message').textContent).toContain('Failed to enable comments');
+ });
+
+ test('executeEnableComments handles fetch rejection exception', async () => {
+ (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-enable-comments'));
+
+ await act(async () => {
+ fireEvent.click(getByTestId('btn-confirm-proceed'));
+ await Promise.resolve();
+ });
+
+ expect(getByTestId('notification-message').textContent).toContain('Failed to enable comments');
+ expect(getByTestId('selected-count').textContent).toBe('0');
+ });
+
+ test('executeDisableComments success', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-disable-comments'));
+
+ await act(async () => {
+ fireEvent.click(getByTestId('btn-confirm-proceed'));
+ await Promise.resolve();
+ });
+
+ expect(getByTestId('notification-message').textContent).toContain('Successfully Disabled comments');
+ expect(getByTestId('selected-count').textContent).toBe('0');
+ });
+
+ test('executeDisableComments handles response.ok = false', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-disable-comments'));
+
+ await act(async () => {
+ fireEvent.click(getByTestId('btn-confirm-proceed'));
+ await Promise.resolve();
+ });
+
+ expect(getByTestId('notification-message').textContent).toContain('Failed to disable comments');
+ });
+
+ test('executeDisableComments handles fetch rejection exception', async () => {
+ (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-disable-comments'));
+
+ await act(async () => {
+ fireEvent.click(getByTestId('btn-confirm-proceed'));
+ await Promise.resolve();
+ });
+
+ expect(getByTestId('notification-message').textContent).toContain('Failed to disable comments');
+ expect(getByTestId('selected-count').textContent).toBe('0');
+ });
+ });
+
+ describe('Download Management Execution', () => {
+ test('executeEnableDownload success', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-enable-download'));
+
+ await act(async () => {
+ fireEvent.click(getByTestId('btn-confirm-proceed'));
+ await Promise.resolve();
+ });
+
+ expect(getByTestId('notification-message').textContent).toContain('Successfully Enabled Download');
+ });
+
+ test('executeEnableDownload handles response.ok = false', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-enable-download'));
+
+ await act(async () => {
+ fireEvent.click(getByTestId('btn-confirm-proceed'));
+ await Promise.resolve();
+ });
+
+ expect(getByTestId('notification-message').textContent).toContain('Failed to enable download');
+ });
+
+ test('executeEnableDownload handles fetch rejection exception', async () => {
+ (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-enable-download'));
+
+ await act(async () => {
+ fireEvent.click(getByTestId('btn-confirm-proceed'));
+ await Promise.resolve();
+ });
+
+ expect(getByTestId('notification-message').textContent).toContain('Failed to enable download');
+ expect(getByTestId('selected-count').textContent).toBe('0');
+ });
+
+ test('executeDisableDownload success', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-disable-download'));
+
+ await act(async () => {
+ fireEvent.click(getByTestId('btn-confirm-proceed'));
+ await Promise.resolve();
+ });
+
+ expect(getByTestId('notification-message').textContent).toContain('Successfully Disabled Download');
+ expect(getByTestId('selected-count').textContent).toBe('0');
+ });
+
+ test('executeDisableDownload handles response.ok = false', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-disable-download'));
+
+ await act(async () => {
+ fireEvent.click(getByTestId('btn-confirm-proceed'));
+ await Promise.resolve();
+ });
+
+ expect(getByTestId('notification-message').textContent).toContain('Failed to disable download');
+ });
+
+ test('executeDisableDownload handles fetch rejection exception', async () => {
+ (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-disable-download'));
+
+ await act(async () => {
+ fireEvent.click(getByTestId('btn-confirm-proceed'));
+ await Promise.resolve();
+ });
+
+ expect(getByTestId('notification-message').textContent).toContain('Failed to disable download');
+ expect(getByTestId('selected-count').textContent).toBe('0');
+ });
+ });
+
+ describe('Copy Media Execution', () => {
+ test('executeCopyMedia success', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-copy'));
+
+ await act(async () => {
+ fireEvent.click(getByTestId('btn-confirm-proceed'));
+ await Promise.resolve();
+ });
+
+ expect(getByTestId('notification-message').textContent).toContain('Successfully Copied');
+ });
+
+ test('executeCopyMedia handles response.ok = false', async () => {
+ (global.fetch as jest.Mock).mockResolvedValue({ ok: false, json: () => Promise.resolve({}) });
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-copy'));
+
+ await act(async () => {
+ fireEvent.click(getByTestId('btn-confirm-proceed'));
+ await Promise.resolve();
+ });
+
+ expect(getByTestId('notification-message').textContent).toContain('Failed to copy media');
+ });
+
+ test('executeCopyMedia handles fetch rejection exception', async () => {
+ (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error'));
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-copy'));
+
+ await act(async () => {
+ fireEvent.click(getByTestId('btn-confirm-proceed'));
+ await Promise.resolve();
+ });
+
+ expect(getByTestId('notification-message').textContent).toContain('Failed to copy media');
+ expect(getByTestId('selected-count').textContent).toBe('0');
+ });
+ });
+
+ describe('Permission Modal Handlers', () => {
+ test('handlePermissionModalCancel closes permission modal', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-perm-viewer'));
+ expect(getByTestId('show-permission').textContent).toBe('true');
+
+ fireEvent.click(getByTestId('btn-perm-cancel'));
+ expect(getByTestId('show-permission').textContent).toBe('false');
+ expect(getByTestId('permission-type').textContent).toBe('');
+ });
+
+ test('handlePermissionModalSuccess shows notification and closes modal', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-perm-success'));
+ expect(getByTestId('notification-message').textContent).toBe('perm ok');
+ expect(getByTestId('show-permission').textContent).toBe('false');
+ });
+
+ test('handlePermissionModalError shows error notification and closes modal', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-perm-error'));
+ expect(getByTestId('notification-message').textContent).toBe('perm err');
+ expect(getByTestId('show-permission').textContent).toBe('false');
+ });
+ });
+
+ describe('Playlist Modal Handlers', () => {
+ test('handlePlaylistModalCancel closes playlist modal', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-playlist'));
+ expect(getByTestId('show-playlist').textContent).toBe('true');
+
+ fireEvent.click(getByTestId('btn-playlist-cancel'));
+ expect(getByTestId('show-playlist').textContent).toBe('false');
+ });
+
+ test('handlePlaylistModalSuccess shows notification and closes modal', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-playlist-success'));
+ expect(getByTestId('notification-message').textContent).toBe('pl ok');
+ expect(getByTestId('show-playlist').textContent).toBe('false');
+ });
+
+ test('handlePlaylistModalError shows error notification and closes modal', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-playlist-error'));
+ expect(getByTestId('notification-message').textContent).toBe('pl err');
+ expect(getByTestId('show-playlist').textContent).toBe('false');
+ });
+ });
+
+ describe('Change Owner Modal Handlers', () => {
+ test('handleChangeOwnerModalCancel closes change owner modal', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-change-owner'));
+ expect(getByTestId('show-change-owner').textContent).toBe('true');
+
+ fireEvent.click(getByTestId('btn-change-owner-cancel'));
+ expect(getByTestId('show-change-owner').textContent).toBe('false');
+ });
+
+ test('handleChangeOwnerModalSuccess shows notification and closes modal', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-change-owner-success'));
+ expect(getByTestId('notification-message').textContent).toBe('owner ok');
+ expect(getByTestId('show-change-owner').textContent).toBe('false');
+ });
+
+ test('handleChangeOwnerModalError shows error notification and closes modal', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-change-owner-error'));
+ expect(getByTestId('notification-message').textContent).toBe('owner err');
+ expect(getByTestId('show-change-owner').textContent).toBe('false');
+ });
+ });
+
+ describe('Publish State Modal Handlers', () => {
+ test('handlePublishStateModalCancel closes publish state modal', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-publish'));
+ expect(getByTestId('show-publish-state').textContent).toBe('true');
+
+ fireEvent.click(getByTestId('btn-publish-cancel'));
+ expect(getByTestId('show-publish-state').textContent).toBe('false');
+ });
+
+ test('handlePublishStateModalSuccess shows notification and closes modal', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-publish-success'));
+ expect(getByTestId('notification-message').textContent).toBe('pub ok');
+ expect(getByTestId('show-publish-state').textContent).toBe('false');
+ });
+
+ test('handlePublishStateModalError shows error notification and closes modal', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-publish-error'));
+ expect(getByTestId('notification-message').textContent).toBe('pub err');
+ expect(getByTestId('show-publish-state').textContent).toBe('false');
+ });
+ });
+
+ describe('Category Modal Handlers', () => {
+ test('handleCategoryModalCancel closes category modal', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-category'));
+ expect(getByTestId('show-category').textContent).toBe('true');
+
+ fireEvent.click(getByTestId('btn-category-cancel'));
+ expect(getByTestId('show-category').textContent).toBe('false');
+ });
+
+ test('handleCategoryModalSuccess shows notification and closes modal', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-category-success'));
+ expect(getByTestId('notification-message').textContent).toBe('cat ok');
+ expect(getByTestId('show-category').textContent).toBe('false');
+ });
+
+ test('handleCategoryModalError shows error notification and closes modal', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-category-error'));
+ expect(getByTestId('notification-message').textContent).toBe('cat err');
+ expect(getByTestId('show-category').textContent).toBe('false');
+ });
+ });
+
+ describe('Tag Modal Handlers', () => {
+ test('handleTagModalCancel closes tag modal', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-handle-media-select'));
+ fireEvent.click(getByTestId('btn-bulk-tag'));
+ expect(getByTestId('show-tag').textContent).toBe('true');
+
+ fireEvent.click(getByTestId('btn-tag-cancel'));
+ expect(getByTestId('show-tag').textContent).toBe('false');
+ });
+
+ test('handleTagModalSuccess shows notification and closes modal', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-tag-success'));
+ expect(getByTestId('notification-message').textContent).toBe('tag ok');
+ expect(getByTestId('show-tag').textContent).toBe('false');
+ });
+
+ test('handleTagModalError shows error notification and closes modal', () => {
+ const { getByTestId } = render();
+
+ fireEvent.click(getByTestId('btn-tag-error'));
+ expect(getByTestId('notification-message').textContent).toBe('tag err');
+ expect(getByTestId('show-tag').textContent).toBe('false');
+ });
+ });
+});
diff --git a/frontend/tests/utils/hooks/useItem.test.tsx b/frontend/tests/utils/hooks/useItem.test.tsx
new file mode 100644
index 00000000..d75514b4
--- /dev/null
+++ b/frontend/tests/utils/hooks/useItem.test.tsx
@@ -0,0 +1,380 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+import { useItem } from '../../../src/static/js/utils/hooks/useItem';
+
+// Mock the item components
+jest.mock('../../../src/static/js/components/list-item/includes/items', () => ({
+ ItemDescription: ({ description }: { description: string }) => (
+ {description}
+ ),
+ ItemMain: ({ children }: { children: React.ReactNode }) => {children}
,
+ ItemMainInLink: ({ children, link, title }: { children: React.ReactNode; link: string; title: string }) => (
+
+ {children}
+
+ ),
+ ItemTitle: ({ title, ariaLabel }: { title: string; ariaLabel: string }) => (
+
+ {title}
+
+ ),
+ ItemTitleLink: ({ title, link, ariaLabel }: { title: string; link: string; ariaLabel: string }) => (
+
+ {title}
+
+ ),
+}));
+
+// Mock PageStore
+jest.mock('../../../src/static/js/utils/stores/PageStore.js', () => ({
+ __esModule: true,
+ default: {
+ get: (key: string) => (key === 'config-site' ? { url: 'https://example.com' } : null),
+ },
+}));
+
+// HookConsumer component to test the hook
+function HookConsumer(props: any) {
+ const { titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper } = useItem(props);
+
+ return (
+
+
{titleComponent()}
+
{descriptionComponent()}
+
{thumbnailUrl || 'null'}
+
{(UnderThumbWrapper as any).name}
+
+
+ );
+}
+
+// Wrapper consumer to test wrapper selection
+function WrapperTest(props: any) {
+ const { UnderThumbWrapper } = useItem(props);
+
+ return (
+
+ Content
+
+ );
+}
+
+describe('utils/hooks', () => {
+ describe('useItem', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('titleComponent Rendering', () => {
+ test('Renders ItemTitle when singleLinkContent is true', () => {
+ const { getByTestId } = render(
+
+ );
+
+ expect(getByTestId('title').querySelector('[data-testid="item-title"]')).toBeTruthy();
+ expect(getByTestId('title').querySelector('[data-testid="item-title-link"]')).toBeFalsy();
+ });
+
+ test('Renders ItemTitleLink when singleLinkContent is false', () => {
+ const { getByTestId } = render(
+
+ );
+
+ expect(getByTestId('title').querySelector('[data-testid="item-title"]')).toBeFalsy();
+ expect(getByTestId('title').querySelector('[data-testid="item-title-link"]')).toBeTruthy();
+ });
+
+ test('Renders with default link when singleLinkContent is not provided', () => {
+ const { getByTestId } = render(
+
+ );
+
+ // Default is false for singleLinkContent
+ expect(getByTestId('title').querySelector('[data-testid="item-title-link"]')).toBeTruthy();
+ });
+ });
+
+ describe('descriptionComponent Rendering', () => {
+ test('Renders single ItemDescription when hasMediaViewer is false', () => {
+ const { getByTestId, queryAllByTestId } = render(
+
+ );
+
+ const descriptions = queryAllByTestId('item-description');
+ expect(descriptions.length).toBe(1);
+ expect(descriptions[0].textContent).toBe('My Description');
+ });
+
+ test('Renders single ItemDescription when hasMediaViewerDescr is false', () => {
+ const { getByTestId, queryAllByTestId } = render(
+
+ );
+
+ const descriptions = queryAllByTestId('item-description');
+ expect(descriptions.length).toBe(1);
+ expect(descriptions[0].textContent).toBe('My Description');
+ });
+
+ test('Renders two ItemDescriptions when hasMediaViewer and hasMediaViewerDescr are both true', () => {
+ const { queryAllByTestId } = render(
+
+ );
+
+ const descriptions = queryAllByTestId('item-description');
+ expect(descriptions.length).toBe(2);
+ expect(descriptions[0].textContent).toBe('Meta Description');
+ expect(descriptions[1].textContent).toBe('Main Description');
+ });
+
+ test('Trims description text', () => {
+ const { queryAllByTestId } = render(
+
+ );
+
+ expect(queryAllByTestId('item-description')[0].textContent).toBe('Description with spaces');
+ });
+
+ test('Trims meta_description text', () => {
+ const { queryAllByTestId } = render(
+
+ );
+
+ expect(queryAllByTestId('item-description')[0].textContent).toBe('Meta with spaces');
+ });
+ });
+
+ describe('thumbnailUrl', () => {
+ test('Returns null when thumbnail is empty string', () => {
+ const { getByTestId } = render(
+
+ );
+
+ expect(getByTestId('thumbnail-url').textContent).toBe('null');
+ });
+
+ test('Returns formatted URL when thumbnail has value', () => {
+ const { getByTestId } = render(
+
+ );
+
+ expect(getByTestId('thumbnail-url').textContent).toBe('https://example.com/media/thumbnail.jpg');
+ });
+
+ test('Handles absolute URLs as thumbnails', () => {
+ const { getByTestId } = render(
+
+ );
+
+ // formatInnerLink should preserve absolute URLs
+ expect(getByTestId('thumbnail-url').textContent).toBe('https://cdn.example.com/image.jpg');
+ });
+ });
+
+ describe('UnderThumbWrapper', () => {
+ test('Uses ItemMainInLink when singleLinkContent is true', () => {
+ const { getByTestId } = render(
+
+ );
+
+ // When singleLinkContent is true, UnderThumbWrapper should be ItemMainInLink
+ expect(getByTestId('item-main-in-link')).toBeTruthy();
+ expect(getByTestId('item-main-in-link').getAttribute('data-link')).toBe('https://example.com');
+ expect(getByTestId('item-main-in-link').getAttribute('data-title')).toBe('Test Title');
+ });
+
+ test('Uses ItemMain when singleLinkContent is false', () => {
+ const { getByTestId } = render(
+
+ );
+
+ // When singleLinkContent is false, UnderThumbWrapper should be ItemMain
+ expect(getByTestId('item-main')).toBeTruthy();
+ });
+
+ test('Uses ItemMain by default when singleLinkContent is not provided', () => {
+ const { getByTestId } = render(
+
+ );
+
+ // Default is singleLinkContent=false, so ItemMain
+ expect(getByTestId('item-main')).toBeTruthy();
+ });
+ });
+
+ describe('onMount callback', () => {
+ test('Calls onMount callback when component mounts', () => {
+ const onMountCallback = jest.fn();
+
+ render(
+
+ );
+
+ expect(onMountCallback).toHaveBeenCalledTimes(1);
+ });
+
+ test('Calls onMount only once on initial mount', () => {
+ const onMountCallback = jest.fn();
+
+ const { rerender } = render(
+
+ );
+
+ expect(onMountCallback).toHaveBeenCalledTimes(1);
+
+ rerender(
+
+ );
+
+ // Should still be called only once (useEffect with empty dependency array)
+ expect(onMountCallback).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Integration tests', () => {
+ test('Complete rendering with all props', () => {
+ const onMount = jest.fn();
+ const { getByTestId, queryAllByTestId } = render(
+
+ );
+
+ const descriptions = queryAllByTestId('item-description');
+ expect(descriptions.length).toBe(2);
+ expect(onMount).toHaveBeenCalledTimes(1);
+ expect(getByTestId('thumbnail-url').textContent).toBe('https://example.com/img/thumb.jpg');
+ });
+
+ test('Minimal props required', () => {
+ const { getByTestId } = render(
+
+ );
+
+ expect(getByTestId('title')).toBeTruthy();
+ expect(getByTestId('description')).toBeTruthy();
+ expect(getByTestId('thumbnail-url').textContent).toBe('null');
+ });
+
+ test('Renders with special characters in title and description', () => {
+ const { queryAllByTestId } = render(
+
+ );
+
+ const descriptions = queryAllByTestId('item-description');
+ expect(descriptions[0].textContent).toContain('Description with');
+ });
+ });
+ });
+});
diff --git a/frontend/tests/utils/hooks/useItemList.test.tsx b/frontend/tests/utils/hooks/useItemList.test.tsx
new file mode 100644
index 00000000..e895b962
--- /dev/null
+++ b/frontend/tests/utils/hooks/useItemList.test.tsx
@@ -0,0 +1,124 @@
+import React, { createRef } from 'react';
+import { render } from '@testing-library/react';
+
+// Stub style imports used by the hook so Jest doesn't try to parse SCSS
+jest.mock('../../../src/static/js/components/item-list/ItemList.scss', () => ({}), { virtual: true });
+
+jest.mock('../../../src/static/js/components/item-list/includes/itemLists/initItemsList', () => ({
+ __esModule: true,
+ default: jest.fn((_lists: any[]) => [{ appendItems: jest.fn() }]),
+}));
+
+import initItemsList from '../../../src/static/js/components/item-list/includes/itemLists/initItemsList';
+import { useItemList } from '../../../src/static/js/utils/hooks/useItemList';
+
+function HookConsumer(props: any) {
+ const listRef = createRef();
+ const [items, countedItems, listHandler, setListHandler, onItemsLoad, onItemsCount, addListItems] = useItemList(
+ props,
+ listRef
+ ) as any[];
+
+ return (
+
+
+ {(items as any[]).map((_, idx) => (
+
+ ))}
+
+
{String(countedItems)}
+
{items.length}
+
onItemsLoad([1, 2])} />
+ onItemsCount(5)} />
+ addListItems()} />
+ setListHandler({ foo: 'bar' })} />
+ {listHandler ? 'yes' : 'no'}
+
+ );
+}
+
+describe('utils/hooks', () => {
+ describe('useItemList', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('Initial state: empty items and not counted', () => {
+ const { getByTestId } = render();
+ expect(getByTestId('counted').textContent).toBe('false');
+ expect(getByTestId('len').textContent).toBe('0');
+ expect(getByTestId('has-handler').textContent).toBe('no');
+ });
+
+ test('onItemsLoad updates items and renders item nodes', () => {
+ const { getByTestId, getByTestId: $ } = render();
+ (getByTestId('load-call') as HTMLButtonElement).click();
+ expect(getByTestId('len').textContent).toBe('2');
+ expect($('itm-0')).toBeTruthy();
+ expect($('itm-1')).toBeTruthy();
+ });
+
+ test('onItemsCount marks countedItems true and triggers callback if provided', () => {
+ const cb = jest.fn();
+ const { getByTestId } = render();
+ (getByTestId('count-call') as HTMLButtonElement).click();
+ expect(getByTestId('counted').textContent).toBe('true');
+ expect(cb).toHaveBeenCalledWith(5);
+ });
+
+ test('addListItems initializes itemsListInstance and appends only new items', () => {
+ const mockInit = initItemsList as jest.Mock;
+
+ const { getByTestId, rerender } = render();
+
+ const itemsLen = getByTestId('len') as HTMLDivElement;
+ const addBtn = getByTestId('add-call') as HTMLButtonElement;
+ const loadBtn = getByTestId('load-call') as HTMLButtonElement;
+
+ expect(itemsLen.textContent).toBe('0');
+ loadBtn.click();
+ expect(itemsLen.textContent).toBe('2');
+
+ expect(mockInit).toHaveBeenCalledTimes(0);
+ addBtn.click();
+ expect(mockInit).toHaveBeenCalledTimes(1);
+
+ expect(mockInit.mock.results[0].value[0].appendItems).toHaveBeenCalledTimes(2);
+
+ loadBtn.click();
+ expect(itemsLen.textContent).toBe('2');
+
+ addBtn.click();
+ expect(mockInit).toHaveBeenCalledTimes(2);
+ expect(mockInit.mock.results[1].value[0].appendItems).toHaveBeenCalledTimes(2);
+
+ rerender();
+
+ addBtn.click();
+ expect(mockInit).toHaveBeenCalledTimes(3);
+ expect(mockInit.mock.results[2].value[0].appendItems).toHaveBeenCalledTimes(2);
+ });
+
+ test('addListItems does nothing when there are no .item elements in the ref', () => {
+ // Render, do not call onItemsLoad, then call addListItems
+ const mockInit = initItemsList as jest.Mock;
+ const { getByTestId } = render();
+ (getByTestId('add-call') as HTMLButtonElement).click();
+ expect(mockInit).not.toHaveBeenCalled();
+ });
+
+ test('itemsLoadCallback is invoked when items change', () => {
+ const itemsLoadCallback = jest.fn();
+ const { getByTestId } = render();
+ (getByTestId('load-call') as HTMLButtonElement).click();
+ expect(itemsLoadCallback).toHaveBeenCalledTimes(1);
+ });
+
+ test('setListHandler updates listHandler', () => {
+ const { getByTestId } = render();
+ expect(getByTestId('has-handler').textContent).toBe('no');
+ (getByTestId('set-handler') as HTMLButtonElement).click();
+ expect(getByTestId('has-handler').textContent).toBe('yes');
+ });
+ });
+});
diff --git a/frontend/tests/utils/hooks/useItemListInlineSlider.test.tsx b/frontend/tests/utils/hooks/useItemListInlineSlider.test.tsx
new file mode 100644
index 00000000..12dff94c
--- /dev/null
+++ b/frontend/tests/utils/hooks/useItemListInlineSlider.test.tsx
@@ -0,0 +1,346 @@
+import React from 'react';
+import { render, fireEvent, act } from '@testing-library/react';
+
+jest.mock('../../../src/static/js/utils/settings/config', () => ({
+ config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
+}));
+
+jest.mock('../../../src/static/js/utils/classes/', () => ({
+ BrowserCache: jest.fn().mockImplementation(() => ({
+ get: jest.fn(),
+ set: jest.fn(),
+ })),
+}));
+
+jest.mock('../../../src/static/js/utils/helpers/', () => ({
+ addClassname: jest.fn(),
+ removeClassname: jest.fn(),
+}));
+
+let mockListHandler: any;
+let mockInlineSliderInstance: any;
+let addListItemsSpy = jest.fn();
+
+jest.mock('../../../src/static/js/utils/hooks/useItemList', () => ({
+ useItemList: (props: any, _ref: any) => {
+ mockListHandler = {
+ loadItems: jest.fn(),
+ totalPages: jest.fn().mockReturnValue(props.__totalPages ?? 1),
+ loadedAllItems: jest.fn().mockReturnValue(Boolean(props.__loadedAll ?? true)),
+ };
+ return [
+ props.__items ?? [], // items
+ props.__countedItems ?? 0, // countedItems
+ mockListHandler, // listHandler
+ jest.fn(), // setListHandler
+ jest.fn(), // onItemsLoad
+ jest.fn(), // onItemsCount
+ addListItemsSpy, // addListItems
+ ];
+ },
+}));
+
+jest.mock('../../../src/static/js/components/item-list/includes/itemLists/ItemsInlineSlider', () =>
+ jest.fn().mockImplementation(() => {
+ mockInlineSliderInstance = {
+ updateDataStateOnResize: jest.fn(),
+ updateDataState: jest.fn(),
+ scrollToCurrentSlide: jest.fn(),
+ nextSlide: jest.fn(),
+ previousSlide: jest.fn(),
+ hasNextSlide: jest.fn().mockReturnValue(true),
+ hasPreviousSlide: jest.fn().mockReturnValue(true),
+ loadItemsToFit: jest.fn().mockReturnValue(false),
+ loadMoreItems: jest.fn().mockReturnValue(false),
+ itemsFit: jest.fn().mockReturnValue(3),
+ };
+ return mockInlineSliderInstance;
+ })
+);
+
+jest.mock('../../../src/static/js/components/_shared', () => ({
+ CircleIconButton: ({ children, onClick }: any) => (
+
+ {children}
+
+ ),
+}));
+
+import { useItemListInlineSlider } from '../../../src/static/js/utils/hooks/useItemListInlineSlider';
+
+function HookConsumer(props: any) {
+ const tuple = useItemListInlineSlider(props);
+ const [
+ _items,
+ _countedItems,
+ _listHandler,
+ classname,
+ _setListHandler,
+ _onItemsCount,
+ _onItemsLoad,
+ _winResizeListener,
+ _sidebarVisibilityChangeListener,
+ itemsListWrapperRef,
+ _itemsListRef,
+ renderBeforeListWrap,
+ renderAfterListWrap,
+ ] = tuple as any;
+
+ return (
+
+
{classname.list}
+
{classname.listOuter}
+
{renderBeforeListWrap()}
+
{renderAfterListWrap()}
+
+ );
+}
+
+describe('utils/hooks', () => {
+ describe('useItemListInlineSlider', () => {
+ beforeEach(() => {
+ addListItemsSpy = jest.fn();
+ mockInlineSliderInstance = null;
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('Returns correct tuple of values from hook', () => {
+ const TestComponent = (props: any) => {
+ const tuple = useItemListInlineSlider(props);
+ return (
+
+
{tuple.length}
+
{tuple[0] ? 'yes' : 'no'}
+
{tuple[3] ? 'yes' : 'no'}
+
{typeof tuple[7] === 'function' ? 'yes' : 'no'}
+
+ );
+ };
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('tuple-length').textContent).toBe('13');
+ expect(getByTestId('has-classname').textContent).toBe('yes');
+ expect(getByTestId('has-listeners').textContent).toBe('yes');
+ });
+
+ test('Computes classname.list and classname.listOuter with optional className prop', () => {
+ const { getByTestId, rerender } = render();
+
+ expect(getByTestId('class-outer').textContent).toBe('items-list-outer list-inline list-slider extra ');
+ expect(getByTestId('class-list').textContent).toBe('items-list');
+
+ rerender();
+
+ expect(getByTestId('class-outer').textContent).toBe('items-list-outer list-inline list-slider');
+ expect(getByTestId('class-list').textContent).toBe('items-list');
+ });
+
+ test('Invokes addListItems when items change', () => {
+ const { rerender } = render();
+ expect(addListItemsSpy).toHaveBeenCalledTimes(1);
+ rerender();
+ expect(addListItemsSpy).toHaveBeenCalledTimes(2);
+ });
+
+ test('nextSlide loads more items when loadMoreItems returns true and not all items loaded', () => {
+ const { getByTestId } = render();
+
+ mockInlineSliderInstance.loadMoreItems.mockReturnValue(true);
+
+ const renderAfter = getByTestId('render-after');
+ const nextButton = renderAfter.querySelector('button[data-testid="circle-icon-button"]');
+
+ expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(1);
+ expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(1);
+ expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(nextButton!);
+
+ expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(1);
+ expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(1);
+ expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(2);
+ expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(2);
+ expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
+ });
+
+ test('nextSlide does not load items when all items already loaded', () => {
+ const { getByTestId } = render();
+
+ mockInlineSliderInstance.loadMoreItems.mockReturnValue(false);
+
+ const renderAfter = getByTestId('render-after');
+ const nextButton = renderAfter.querySelector('button[data-testid="circle-icon-button"]');
+
+ expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(1);
+ expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(1);
+ expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(nextButton!);
+
+ expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(1);
+ expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(2);
+ expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(2);
+ expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(2);
+ });
+
+ test('prevSlide calls inlineSlider.previousSlide and updates button view', () => {
+ const { getByTestId } = render();
+
+ mockInlineSliderInstance.loadMoreItems.mockReturnValue(true);
+
+ const renderBefore = getByTestId('render-before');
+ const prevButton = renderBefore.querySelector('button[data-testid="circle-icon-button"]');
+
+ expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(1);
+ expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(1);
+ expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(prevButton!);
+
+ expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(1);
+ expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(2);
+ expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(2);
+ expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(2);
+ });
+
+ test('prevSlide always scrolls to current slide regardless of item load state', () => {
+ const { getByTestId } = render();
+
+ mockInlineSliderInstance.loadMoreItems.mockReturnValue(false);
+
+ const renderBefore = getByTestId('render-before');
+ const prevButton = renderBefore.querySelector('button[data-testid="circle-icon-button"]');
+
+ expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(1);
+ expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(1);
+ expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(prevButton!);
+
+ expect(mockListHandler.loadItems).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.loadMoreItems).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.nextSlide).toHaveBeenCalledTimes(0);
+ expect(mockInlineSliderInstance.previousSlide).toHaveBeenCalledTimes(1);
+ expect(mockInlineSliderInstance.hasNextSlide).toHaveBeenCalledTimes(2);
+ expect(mockInlineSliderInstance.hasPreviousSlide).toHaveBeenCalledTimes(2);
+ expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(2);
+ });
+
+ test('Button state updates based on hasNextSlide and hasPreviousSlide', () => {
+ const { getByTestId, rerender } = render();
+
+ const renderBefore = getByTestId('render-before');
+ const renderAfter = getByTestId('render-after');
+
+ // Initially should show buttons (default mock returns true)
+ expect(renderBefore.querySelector('button')).toBeTruthy();
+ expect(renderAfter.querySelector('button')).toBeTruthy();
+
+ // Now set hasNextSlide and hasPreviousSlide to false
+ mockInlineSliderInstance.hasNextSlide.mockReturnValue(false);
+ mockInlineSliderInstance.hasPreviousSlide.mockReturnValue(false);
+
+ // Trigger re-render by changing items
+ rerender();
+
+ // The next and previous buttons should not be rendered now
+ const newRenderAfter = getByTestId('render-after');
+ const newRenderBefore = getByTestId('render-before');
+ expect(newRenderAfter.querySelector('button')).toBeNull();
+ expect(newRenderBefore.querySelector('button')).toBeNull();
+ });
+
+ test('winResizeListener and sidebarVisibilityChangeListener are returned as callable functions', () => {
+ const TestComponentWithListeners = (props: any) => {
+ const tuple = useItemListInlineSlider(props);
+
+ const winResizeListener = tuple[7]; // winResizeListener
+ const sidebarListener = tuple[8]; // sidebarVisibilityChangeListener
+ const wrapperRef = tuple[9]; // itemsListWrapperRef
+
+ return (
+
+
+ Trigger Resize
+
+
+ Trigger Sidebars
+
+
+ );
+ };
+
+ const { getByTestId } = render();
+
+ // Should not throw when called
+ const resizeButton = getByTestId('trigger-resize');
+ const sidebarButton = getByTestId('trigger-sidebar');
+
+ expect(() => fireEvent.click(resizeButton)).not.toThrow();
+ expect(() => fireEvent.click(sidebarButton)).not.toThrow();
+ });
+
+ test('winResizeListener updates resizeDate state triggering resize effect', () => {
+ const TestComponent = (props: any) => {
+ const tuple = useItemListInlineSlider(props) as any;
+ const winResizeListener = tuple[7];
+ const wrapperRef = tuple[9];
+
+ return (
+
+
+ Trigger Resize
+
+
+ );
+ };
+
+ const { getByTestId } = render();
+
+ expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(1);
+ expect(mockInlineSliderInstance.updateDataStateOnResize).toHaveBeenCalledTimes(0);
+
+ jest.useFakeTimers();
+
+ fireEvent.click(getByTestId('trigger-resize'));
+
+ expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(2);
+ expect(mockInlineSliderInstance.updateDataStateOnResize).toHaveBeenCalledTimes(1);
+
+ jest.advanceTimersByTime(200);
+
+ expect(mockInlineSliderInstance.scrollToCurrentSlide).toHaveBeenCalledTimes(3);
+ expect(mockInlineSliderInstance.updateDataStateOnResize).toHaveBeenCalledTimes(2);
+
+ jest.useRealTimers();
+ });
+ });
+});
diff --git a/frontend/tests/utils/hooks/useItemListLazyLoad.test.tsx b/frontend/tests/utils/hooks/useItemListLazyLoad.test.tsx
new file mode 100644
index 00000000..e776b4b5
--- /dev/null
+++ b/frontend/tests/utils/hooks/useItemListLazyLoad.test.tsx
@@ -0,0 +1,190 @@
+import React from 'react';
+import { render, fireEvent, waitFor } from '@testing-library/react';
+
+jest.mock('../../../src/static/js/utils/settings/config', () => ({
+ config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
+}));
+
+jest.mock('../../../src/static/js/utils/classes/', () => ({
+ BrowserCache: jest.fn().mockImplementation(() => ({
+ get: jest.fn(),
+ set: jest.fn(),
+ })),
+}));
+
+let mockListHandler: any;
+let addListItemsSpy = jest.fn();
+const mockRemoveListener = jest.fn();
+
+jest.mock('../../../src/static/js/utils/hooks/useItemList', () => ({
+ useItemList: (props: any, _ref: any) => {
+ mockListHandler = {
+ loadItems: jest.fn(),
+ totalPages: jest.fn().mockReturnValue(props.__totalPages ?? 1),
+ loadedAllItems: jest.fn().mockReturnValue(Boolean(props.__loadedAll ?? true)),
+ };
+ return [
+ props.__items ?? [], // items
+ props.__countedItems ?? 0, // countedItems
+ mockListHandler, // listHandler
+ jest.fn(), // setListHandler
+ jest.fn(), // onItemsLoad
+ jest.fn(), // onItemsCount
+ addListItemsSpy, // addListItems
+ ];
+ },
+}));
+
+jest.mock('../../../src/static/js/utils/stores/', () => ({
+ PageStore: {
+ removeListener: mockRemoveListener,
+ },
+}));
+
+import { useItemListLazyLoad } from '../../../src/static/js/utils/hooks/useItemListLazyLoad';
+
+function HookConsumer(props: any) {
+ const tuple = useItemListLazyLoad(props);
+
+ const [
+ _items,
+ _countedItems,
+ _listHandler,
+ _setListHandler,
+ classname,
+ _onItemsCount,
+ _onItemsLoad,
+ _onWindowScroll,
+ _onDocumentVisibilityChange,
+ _itemsListWrapperRef,
+ _itemsListRef,
+ renderBeforeListWrap,
+ renderAfterListWrap,
+ ] = tuple as any;
+
+ return (
+
+
{classname.list}
+
{classname.listOuter}
+
{renderBeforeListWrap()}
+
{renderAfterListWrap()}
+
+ );
+}
+
+function HookConsumerWithRefs(props: any) {
+ const tuple = useItemListLazyLoad(props);
+ const [
+ _items,
+ _countedItems,
+ _listHandler,
+ _setListHandler,
+ classname,
+ _onItemsCount,
+ _onItemsLoad,
+ onWindowScroll,
+ onDocumentVisibilityChange,
+ itemsListWrapperRef,
+ itemsListRef,
+ renderBeforeListWrap,
+ renderAfterListWrap,
+ ] = tuple as any;
+
+ return (
+
+
{classname.list}
+
{classname.listOuter}
+
+
{renderBeforeListWrap()}
+
{renderAfterListWrap()}
+
+ visibility
+
+
+ scroll
+
+
+ );
+}
+
+describe('utils/hooks', () => {
+ describe('useItemListLazyLoad', () => {
+ beforeEach(() => {
+ addListItemsSpy = jest.fn();
+ mockRemoveListener.mockClear();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ test('Computes classname.list and classname.listOuter with optional className prop', () => {
+ const { getByTestId, rerender } = render();
+ expect(getByTestId('class-outer').textContent).toBe('items-list-outer extra');
+ expect(getByTestId('class-list').textContent).toBe('items-list');
+ rerender();
+ expect(getByTestId('class-outer').textContent).toBe('items-list-outer');
+ expect(getByTestId('class-list').textContent).toBe('items-list');
+ });
+
+ test('Invokes addListItems when items change', () => {
+ const { rerender } = render();
+ expect(addListItemsSpy).toHaveBeenCalledTimes(1);
+ rerender();
+ expect(addListItemsSpy).toHaveBeenCalledTimes(2);
+ });
+
+ test('Renders nothing in renderBeforeListWrap and renderAfterListWrap', () => {
+ const { getByTestId } = render(
+
+ );
+ expect(getByTestId('render-before').textContent).toBe('');
+ expect(getByTestId('render-after').textContent).toBe('');
+ });
+
+ test('Does not call listHandler.loadItems when refs are not attached', () => {
+ render();
+ expect(mockListHandler.loadItems).not.toHaveBeenCalled();
+ });
+
+ test('Calls listHandler.loadItems when refs are set and scroll threshold is reached', async () => {
+ render();
+ await waitFor(() => {
+ expect(mockListHandler.loadItems).toHaveBeenCalled();
+ });
+ });
+
+ test('Calls PageStore.removeListener when refs are set and loadedAllItems is true', () => {
+ render();
+ expect(mockRemoveListener).toHaveBeenCalledWith('window_scroll', expect.any(Function));
+ });
+
+ test('onDocumentVisibilityChange schedules onWindowScroll when document is visible', () => {
+ jest.useFakeTimers();
+ const setTimeoutSpy = jest.spyOn(globalThis, 'setTimeout');
+ Object.defineProperty(document, 'hidden', { value: false, configurable: true });
+
+ const { getByTestId } = render();
+ fireEvent.click(getByTestId('trigger-visibility'));
+
+ expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 10);
+
+ setTimeoutSpy.mockRestore();
+ jest.useRealTimers();
+ });
+
+ test('onDocumentVisibilityChange does nothing when document is hidden', () => {
+ jest.useFakeTimers();
+ const setTimeoutSpy = jest.spyOn(globalThis, 'setTimeout');
+ Object.defineProperty(document, 'hidden', { value: true, configurable: true });
+
+ const { getByTestId } = render();
+ fireEvent.click(getByTestId('trigger-visibility'));
+
+ expect(setTimeoutSpy).toHaveBeenCalledTimes(0);
+
+ setTimeoutSpy.mockRestore();
+ jest.useRealTimers();
+ });
+ });
+});
diff --git a/frontend/tests/utils/hooks/useItemListSync.test.tsx b/frontend/tests/utils/hooks/useItemListSync.test.tsx
new file mode 100644
index 00000000..b9f7d827
--- /dev/null
+++ b/frontend/tests/utils/hooks/useItemListSync.test.tsx
@@ -0,0 +1,156 @@
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react';
+
+jest.mock('../../../src/static/js/utils/settings/config', () => ({
+ config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
+}));
+
+jest.mock('../../../src/static/js/utils/classes/', () => ({
+ BrowserCache: jest.fn().mockImplementation(() => ({
+ get: jest.fn(),
+ set: jest.fn(),
+ })),
+}));
+
+jest.mock('../../../src/static/js/utils/helpers/', () => ({
+ translateString: (s: string) => s,
+}));
+
+let mockListHandler: any;
+let mockOnItemsLoad = jest.fn();
+let mockOnItemsCount = jest.fn();
+let addListItemsSpy = jest.fn();
+
+// Mock useItemList to control items, counts, and listHandler
+jest.mock('../../../src/static/js/utils/hooks/useItemList', () => ({
+ useItemList: (props: any, _ref: any) => {
+ mockListHandler = {
+ loadItems: jest.fn(),
+ totalPages: jest.fn().mockReturnValue(props.__totalPages ?? 1),
+ loadedAllItems: jest.fn().mockReturnValue(Boolean(props.__loadedAll ?? true)),
+ };
+ return [
+ props.__items ?? [], // items
+ props.__countedItems ?? 0, // countedItems
+ mockListHandler, // listHandler
+ jest.fn(), // setListHandler
+ mockOnItemsLoad, // onItemsLoad
+ mockOnItemsCount, // onItemsCount
+ addListItemsSpy, // addListItems
+ ];
+ },
+}));
+
+import { useItemListSync } from '../../../src/static/js/utils/hooks/useItemListSync';
+
+function HookConsumer(props: any) {
+ const tuple = useItemListSync(props);
+
+ const [
+ _countedItems,
+ _items,
+ _listHandler,
+ _setListHandler,
+ classname,
+ _itemsListWrapperRef,
+ _itemsListRef,
+ _onItemsCount,
+ _onItemsLoad,
+ renderBeforeListWrap,
+ renderAfterListWrap,
+ ] = tuple as any;
+
+ return (
+
+ {/*
{String(countedItems)}
*/}
+ {/*
{Array.isArray(items) ? items.length : 0}
*/}
+
{classname.list}
+
{classname.listOuter}
+ {/*
{listHandler ? 'yes' : 'no'}
*/}
+ {/*
{itemsListWrapperRef.current ? 'set' : 'unset'}
*/}
+ {/*
{itemsListRef.current ? 'set' : 'unset'}
*/}
+
{renderBeforeListWrap()}
+
{renderAfterListWrap()}
+ {/*
onItemsLoad([])} /> */}
+ {/* onItemsCount(0)} /> */}
+
+ );
+}
+
+describe('utils/hooks', () => {
+ describe('useItemListSync', () => {
+ beforeEach(() => {
+ mockOnItemsLoad = jest.fn();
+ mockOnItemsCount = jest.fn();
+ addListItemsSpy = jest.fn();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('Classname Management', () => {
+ test('Computes classname.listOuter with optional className prop', () => {
+ const { getByTestId, rerender } = render();
+ expect(getByTestId('class-outer').textContent).toBe('items-list-outer extra');
+ expect(getByTestId('class-list').textContent).toBe('items-list');
+ rerender();
+ expect(getByTestId('class-outer').textContent).toBe('items-list-outer');
+ expect(getByTestId('class-list').textContent).toBe('items-list');
+ });
+ });
+
+ describe('Items Management', () => {
+ test('Invokes addListItems and afterItemsLoad when items change', () => {
+ const { rerender } = render();
+ expect(addListItemsSpy).toHaveBeenCalledTimes(1);
+ rerender();
+ // useEffect runs again due to items change
+ expect(addListItemsSpy).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('Load More Button Rendering', () => {
+ test('Renders SHOW MORE button when more pages exist and not loaded all', () => {
+ const { getByTestId } = render(
+
+ );
+ const btn = getByTestId('render-after').querySelector('button.load-more') as HTMLButtonElement;
+ expect(btn).toBeTruthy();
+ expect(btn.textContent).toBe('SHOW MORE');
+ fireEvent.click(btn);
+ expect(mockListHandler.loadItems).toHaveBeenCalledTimes(1);
+ });
+
+ test('Hides SHOW MORE when totalPages <= 1', () => {
+ const { getByTestId } = render(
+ // With totalPages=1 the hook should not render the button regardless of loadedAll
+
+ );
+ expect(getByTestId('render-after').textContent).toBe('');
+ });
+
+ test('Hides SHOW MORE when loadedAllItems is true', () => {
+ const { getByTestId } = render(
+
+ );
+ expect(getByTestId('render-after').textContent).toBe('');
+ });
+
+ test('Shows SHOW MORE when loadedAllItems is false even with totalPages > 1', () => {
+ const { getByTestId } = render(
+
+ );
+ const btn = getByTestId('render-after').querySelector('button.load-more');
+ expect(btn).toBeTruthy();
+ });
+
+ test('Returns null from renderBeforeListWrap', () => {
+ const { getByTestId } = render(
+
+ );
+ expect(getByTestId('render-before').textContent).toBe('');
+ });
+ });
+ });
+});
diff --git a/frontend/tests/utils/hooks/useLayout.test.tsx b/frontend/tests/utils/hooks/useLayout.test.tsx
new file mode 100644
index 00000000..eb18d249
--- /dev/null
+++ b/frontend/tests/utils/hooks/useLayout.test.tsx
@@ -0,0 +1,118 @@
+import React from 'react';
+import { act, render } from '@testing-library/react';
+
+import { useLayout } from '../../../src/static/js/utils/hooks/useLayout';
+
+jest.mock('../../../src/static/js/utils/classes/', () => ({
+ BrowserCache: jest.fn().mockImplementation(() => ({
+ get: (key: string) => {
+ let result: any = undefined;
+ switch (key) {
+ case 'visible-sidebar':
+ result = true;
+ break;
+ }
+ return result;
+ },
+ set: jest.fn(),
+ })),
+}));
+
+jest.mock('../../../src/static/js/utils/dispatcher.js', () => ({
+ register: jest.fn(),
+}));
+
+jest.mock('../../../src/static/js/utils/settings/config', () => ({
+ config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
+}));
+
+import { LayoutProvider } from '../../../src/static/js/utils/contexts';
+
+describe('utils/hooks', () => {
+ describe('useLayout', () => {
+ test('Returns default value', () => {
+ let received: ReturnType | undefined;
+
+ const Comp: React.FC = () => {
+ received = useLayout();
+ return null;
+ };
+
+ render(
+
+
+
+ );
+
+ expect(received).toStrictEqual({
+ enabledSidebar: false,
+ visibleSidebar: true,
+ visibleMobileSearch: false,
+ setVisibleSidebar: expect.any(Function),
+ toggleMobileSearch: expect.any(Function),
+ toggleSidebar: expect.any(Function),
+ });
+ });
+
+ test('Returns undefined value when used without a Provider', () => {
+ let received: any = 'init';
+
+ const Comp: React.FC = () => {
+ received = useLayout();
+ return null;
+ };
+
+ render();
+
+ expect(received).toBe(undefined);
+ });
+
+ test('Toggle sidebar', () => {
+ jest.useFakeTimers();
+
+ let received: ReturnType | undefined;
+
+ const Comp: React.FC = () => {
+ received = useLayout();
+ return null;
+ };
+
+ render(
+
+
+
+ );
+
+ act(() => received?.toggleSidebar());
+ jest.advanceTimersByTime(241);
+ expect(received?.visibleSidebar).toBe(false);
+
+ act(() => received?.toggleSidebar());
+ jest.advanceTimersByTime(241);
+ expect(received?.visibleSidebar).toBe(true);
+
+ jest.useRealTimers();
+ });
+
+ test('Toggle mobile search', () => {
+ let received: ReturnType | undefined;
+
+ const Comp: React.FC = () => {
+ received = useLayout();
+ return null;
+ };
+
+ render(
+
+
+
+ );
+
+ act(() => received?.toggleMobileSearch());
+ expect(received?.visibleMobileSearch).toBe(true);
+
+ act(() => received?.toggleMobileSearch());
+ expect(received?.visibleMobileSearch).toBe(false);
+ });
+ });
+});
diff --git a/frontend/tests/utils/hooks/useManagementTableHeader.test.tsx b/frontend/tests/utils/hooks/useManagementTableHeader.test.tsx
new file mode 100644
index 00000000..da9f7704
--- /dev/null
+++ b/frontend/tests/utils/hooks/useManagementTableHeader.test.tsx
@@ -0,0 +1,134 @@
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react';
+import { useManagementTableHeader } from '../../../src/static/js/utils/hooks/useManagementTableHeader';
+
+function HookConsumer(props: {
+ order: 'asc' | 'desc';
+ selected: boolean;
+ sort: string;
+ type: 'comments' | 'media' | 'users';
+ onCheckAllRows?: (newSort: string, newOrder: 'asc' | 'desc') => void;
+ onClickColumnSort?: (newSelected: boolean, newType: 'comments' | 'media' | 'users') => void;
+}) {
+ const tuple = useManagementTableHeader(props) as [
+ string,
+ 'asc' | 'desc',
+ boolean,
+ React.MouseEventHandler,
+ () => void,
+ ];
+
+ const [sort, order, isSelected, sortByColumn, checkAll] = tuple;
+
+ return (
+
+
{sort}
+
{order}
+
{String(isSelected)}
+
+
+
+
+ );
+}
+
+describe('utils/hooks', () => {
+ describe('useManagementTableHeader', () => {
+ test('Returns a 5-tuple in expected order and reflects initial props', () => {
+ let tuple: any;
+
+ const Comp: React.FC = () => {
+ tuple = useManagementTableHeader({ sort: 'title', order: 'asc', selected: false });
+ return null;
+ };
+
+ render();
+
+ expect(Array.isArray(tuple)).toBe(true);
+ expect(tuple).toHaveLength(5);
+
+ const [sort, order, isSelected] = tuple;
+
+ expect(sort).toBe('title');
+ expect(order).toBe('asc');
+ expect(isSelected).toBe(false);
+ });
+
+ test('sortByColumn toggles order when clicking same column and updates sort when clicking different column', () => {
+ const onClickColumnSort = jest.fn();
+
+ const { getByTestId, rerender } = render(
+
+ );
+
+ // Initial state
+ expect(getByTestId('sort').textContent).toBe('title');
+ expect(getByTestId('order').textContent).toBe('desc');
+
+ // Click same column -> toggle order to asc
+ fireEvent.click(getByTestId('col-title'));
+ expect(onClickColumnSort).toHaveBeenLastCalledWith('title', 'asc');
+
+ // Rerender to ensure state settled in testing DOM
+ rerender(
+
+ );
+
+ // Click same column -> toggle order to desc
+ fireEvent.click(getByTestId('col-title'));
+ expect(onClickColumnSort).toHaveBeenLastCalledWith('title', 'desc');
+
+ // Click different column -> set sort to that column and default order desc
+ fireEvent.click(getByTestId('col-views'));
+ expect(onClickColumnSort).toHaveBeenLastCalledWith('views', 'desc');
+ });
+
+ test('checkAll inverts current selection and invokes callback with newSelected and type', () => {
+ const onCheckAllRows = jest.fn();
+
+ const { getByTestId } = render(
+
+ );
+
+ expect(getByTestId('selected').textContent).toBe('false');
+ fireEvent.click(getByTestId('check-all'));
+
+ // newSelected computed as !isSelected -> true
+ expect(onCheckAllRows).toHaveBeenCalledWith(true, 'media');
+ });
+
+ test('Effects update internal state when props change', () => {
+ const { getByTestId, rerender } = render(
+
+ );
+
+ expect(getByTestId('sort').textContent).toBe('title');
+ expect(getByTestId('order').textContent).toBe('asc');
+ expect(getByTestId('selected').textContent).toBe('false');
+
+ rerender();
+
+ expect(getByTestId('sort').textContent).toBe('views');
+ expect(getByTestId('order').textContent).toBe('desc');
+ expect(getByTestId('selected').textContent).toBe('true');
+ });
+
+ test('Does not throw when optional callbacks are not provided', () => {
+ const { getByTestId } = render();
+ expect(() => fireEvent.click(getByTestId('col-title'))).not.toThrow();
+ expect(() => fireEvent.click(getByTestId('check-all'))).not.toThrow();
+ });
+ });
+});
diff --git a/frontend/tests/utils/hooks/useMediaFilter.test.tsx b/frontend/tests/utils/hooks/useMediaFilter.test.tsx
new file mode 100644
index 00000000..4296c76b
--- /dev/null
+++ b/frontend/tests/utils/hooks/useMediaFilter.test.tsx
@@ -0,0 +1,119 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+import { useMediaFilter } from '../../../src/static/js/utils/hooks/useMediaFilter';
+
+jest.mock('../../../src/static/js/components/_shared/popup/PopupContent', () => ({
+ PopupContent: (props: any) => React.createElement('div', props, props.children),
+}));
+
+jest.mock('../../../src/static/js/components/_shared/popup/PopupTrigger', () => ({
+ PopupTrigger: (props: any) => React.createElement('div', props, props.children),
+}));
+
+function HookConsumer({ initial }: { initial: string }) {
+ const tuple = useMediaFilter(initial) as [
+ React.RefObject,
+ string,
+ React.Dispatch>,
+ React.RefObject,
+ React.ReactNode,
+ React.ReactNode,
+ ];
+
+ const [containerRef, value, setValue, popupContentRef, PopupContent, PopupTrigger] = tuple;
+
+ return (
+
+
{containerRef && typeof containerRef === 'object' ? 'ok' : 'bad'}
+
{value}
+
setValue('updated')} />
+ {popupContentRef && typeof popupContentRef === 'object' ? 'ok' : 'bad'}
+ {typeof PopupContent === 'function' ? React.createElement(PopupContent, null, 'c') : null}
+ {typeof PopupTrigger === 'function' ? React.createElement(PopupTrigger, null, 't') : null}
+
+ );
+}
+
+describe('utils/hooks', () => {
+ describe('useMediaFilter', () => {
+ test('Returns a 6-tuple in expected order', () => {
+ let tuple: any;
+
+ const Comp: React.FC = () => {
+ tuple = useMediaFilter('init');
+ return null;
+ };
+
+ render();
+
+ expect(Array.isArray(tuple)).toBe(true);
+ expect(tuple).toHaveLength(6);
+
+ const [containerRef, value, setValue, popupContentRef, PopupContent, PopupTrigger] = tuple;
+
+ expect(containerRef).toBeDefined();
+ expect(containerRef.current).toBe(null);
+ expect(value).toBe('init');
+ expect(typeof setValue).toBe('function');
+ expect(popupContentRef).toBeDefined();
+ expect(typeof PopupContent).toBe('function');
+ expect(typeof PopupTrigger).toBe('function');
+ });
+
+ test('Initial value is respected and can be updated via setter', () => {
+ const { getByTestId } = render();
+ expect(getByTestId('value').textContent).toBe('first');
+ getByTestId('set').click();
+ expect(getByTestId('value').textContent).toBe('updated');
+ });
+
+ test('containerRef and popupContentRef are mutable ref objects', () => {
+ let data: any;
+
+ const Comp: React.FC = () => {
+ data = useMediaFilter('x');
+ return null;
+ };
+
+ render();
+
+ const [containerRef, _value, _setValue, popupContentRef] = data;
+
+ expect(containerRef.current).toBe(null);
+ expect(popupContentRef.current).toBe(null);
+ });
+
+ test('PopupContent and PopupTrigger are stable functions', () => {
+ let first: any;
+ let second: any;
+
+ const First: React.FC = () => {
+ first = useMediaFilter('a');
+ return null;
+ };
+
+ const Second: React.FC = () => {
+ second = useMediaFilter('b');
+ return null;
+ };
+
+ const Parent: React.FC = () => (
+ <>
+
+
+ >
+ );
+
+ render();
+
+ const [, , , , PopupContent1, PopupTrigger1] = first;
+ const [, , , , PopupContent2, PopupTrigger2] = second;
+
+ expect(typeof PopupContent1).toBe('function');
+ expect(typeof PopupTrigger1).toBe('function');
+
+ expect(PopupContent1).toBe(PopupContent2);
+ expect(PopupTrigger1).toBe(PopupTrigger2);
+ });
+ });
+});
diff --git a/frontend/tests/utils/hooks/useMediaItem.test.tsx b/frontend/tests/utils/hooks/useMediaItem.test.tsx
new file mode 100644
index 00000000..9dd035db
--- /dev/null
+++ b/frontend/tests/utils/hooks/useMediaItem.test.tsx
@@ -0,0 +1,289 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+import { useMediaItem, itemClassname } from '../../../src/static/js/utils/hooks/useMediaItem';
+
+// Mock dependencies used by useMediaItem
+
+// @todo: Revisit this
+jest.mock('../../../src/static/js/utils/stores/', () => ({
+ PageStore: { get: (_: string) => ({ url: 'https://example.com' }) },
+}));
+
+jest.mock('../../../src/static/js/components/list-item/includes/items', () => ({
+ MediaItemAuthor: ({ name }: any) => ,
+ MediaItemAuthorLink: ({ name, link }: any) => (
+
+ ),
+ MediaItemMetaViews: ({ views }: any) => ,
+ MediaItemMetaDate: ({ time, dateTime, text }: any) => (
+
+ ),
+ MediaItemEditLink: ({ link }: any) => ,
+ MediaItemViewLink: ({ link }: any) => ,
+}));
+
+// @todo: Revisit this
+// useItem returns titleComponent, descriptionComponent, thumbnailUrl, UnderThumbWrapper
+jest.mock('../../../src/static/js/utils/hooks/useItem', () => ({
+ useItem: (props: any) => ({
+ titleComponent: () => {props.title || 'title'}
,
+ descriptionComponent: () => {props.description || 'desc'}
,
+ thumbnailUrl: props.thumb || 'thumb.jpg',
+ UnderThumbWrapper: ({ children }: any) => {children}
,
+ }),
+}));
+
+function HookConsumer(props: any) {
+ const [TitleComp, DescComp, thumbUrl, UnderThumbComp, EditComp, MetaComp, ViewComp] = useMediaItem(props);
+ // The hook returns functions/components/values. To satisfy TS, render using React.createElement
+ return (
+
+ {typeof TitleComp === 'function' ? React.createElement(TitleComp) : null}
+ {typeof DescComp === 'function' ? React.createElement(DescComp) : null}
+
{typeof thumbUrl === 'string' ? thumbUrl : ''}
+ {typeof UnderThumbComp === 'function'
+ ? React.createElement(
+ UnderThumbComp,
+ null,
+ typeof EditComp === 'function' ? React.createElement(EditComp) : null,
+ typeof MetaComp === 'function' ? React.createElement(MetaComp) : null,
+ typeof ViewComp === 'function' ? React.createElement(ViewComp) : null
+ )
+ : null}
+
+ );
+}
+
+describe('utils/hooks', () => {
+ describe('useMediaItem', () => {
+ describe('itemClassname utility function', () => {
+ test('Returns default classname when no modifications', () => {
+ expect(itemClassname('base', '', false)).toBe('base');
+ });
+
+ test('Appends inherited classname when provided', () => {
+ expect(itemClassname('base', 'extra', false)).toBe('base extra');
+ });
+
+ test('Appends pl-active-item when isActiveInPlaylistPlayback is true', () => {
+ expect(itemClassname('base', '', true)).toBe('base pl-active-item');
+ });
+
+ test('Appends both inherited classname and active state', () => {
+ expect(itemClassname('base', 'extra', true)).toBe('base extra pl-active-item');
+ });
+ });
+
+ describe('Basic Rendering', () => {
+ test('Renders basic components from useItem and edit/view links', () => {
+ // @todo: Revisit this
+ const props = {
+ title: 'My Title',
+ description: 'My Desc',
+ thumbnail: 'thumb.jpg',
+ link: '/watch/1',
+ singleLinkContent: true,
+ // hasMediaViewer:...
+ // hasMediaViewerDescr:...
+ // meta_description:...
+ // onMount:...
+ // type:...
+ // ------------------------------
+ editLink: '/edit/1',
+ showSelection: true,
+ // publishLink: ...
+ // hideAuthor:...
+ author_name: 'Author',
+ author_link: '/u/author',
+ // hideViews:...
+ views: 10,
+ // hideDate:...
+ publish_date: '2020-01-01T00:00:00Z',
+ // hideAllMeta:...
+ };
+
+ const { getByTestId, queryByTestId } = render();
+
+ expect(getByTestId('title').textContent).toBe(props.title);
+ expect(getByTestId('desc').textContent).toBe(props.description);
+ expect(getByTestId('thumb').textContent).toBe('thumb.jpg');
+
+ expect(getByTestId('edit').getAttribute('href')).toBe(props.editLink);
+
+ expect(getByTestId('views').getAttribute('data-views')).toBe(props.views.toString());
+ expect(getByTestId('date')).toBeTruthy();
+ expect(getByTestId('view').getAttribute('href')).toBe(props.link);
+ expect(queryByTestId('author')).toBeTruthy();
+ });
+ });
+
+ describe('View Link Selection', () => {
+ test('Uses publishLink when provided and showSelection=true', () => {
+ const props = {
+ editLink: '/edit/2',
+ link: '/watch/2',
+ publishLink: '/publish/2',
+ showSelection: true,
+ singleLinkContent: true,
+ author_name: 'A',
+ author_link: '',
+ views: 0,
+ publish_date: 0,
+ };
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('view').getAttribute('href')).toBe(props.publishLink);
+ });
+ });
+
+ describe('Visibility Controls', () => {
+ test('Hides author, views, and date based on props', () => {
+ const props = {
+ editLink: '/e',
+ link: '/l',
+ showSelection: true,
+ hideAuthor: true,
+ hideViews: true,
+ hideDate: true,
+ publish_date: '2020-01-01T00:00:00Z',
+ views: 5,
+ author_name: 'Hidden',
+ author_link: '/u/x',
+ };
+
+ const { queryByTestId } = render();
+
+ expect(queryByTestId('author')).toBeNull();
+ expect(queryByTestId('views')).toBeNull();
+ expect(queryByTestId('date')).toBeNull();
+ });
+
+ test('Author link resolves using formatInnerLink and PageStore base url when singleLinkContent=false', () => {
+ const props = {
+ editLink: '/e',
+ link: '/l',
+ showSelection: true,
+ singleLinkContent: false,
+ hideAuthor: false,
+ author_name: 'John',
+ author_link: '/u/john',
+ publish_date: '2020-01-01T00:00:00Z',
+ };
+
+ const { container } = render();
+
+ const a = container.querySelector('[data-testid="author-link"]') as HTMLAnchorElement;
+
+ expect(a).toBeTruthy();
+ expect(a.getAttribute('href')).toBe(`https://example.com${props.author_link}`);
+ expect(a.getAttribute('data-name')).toBe(props.author_name);
+ });
+ });
+
+ describe('Meta Visibility', () => {
+ test('Meta wrapper hidden when hideAllMeta=true', () => {
+ const props = {
+ editLink: '/e',
+ link: '/l',
+ showSelection: true,
+ hideAllMeta: true,
+ publish_date: '2020-01-01T00:00:00Z',
+ };
+
+ const { queryByTestId } = render();
+
+ expect(queryByTestId('author')).toBeNull();
+ expect(queryByTestId('views')).toBeNull();
+ expect(queryByTestId('date')).toBeNull();
+ });
+
+ test('Meta wrapper hidden individually by hideAuthor, hideViews, hideDate', () => {
+ const props = {
+ editLink: '/e',
+ link: '/l',
+ showSelection: true,
+ hideAuthor: true,
+ hideViews: false,
+ hideDate: false,
+ publish_date: '2020-01-01T00:00:00Z',
+ views: 5,
+ author_name: 'Test',
+ author_link: '/u/test',
+ };
+
+ const { queryByTestId } = render();
+
+ expect(queryByTestId('author')).toBeNull();
+ expect(queryByTestId('views')).toBeTruthy();
+ expect(queryByTestId('date')).toBeTruthy();
+ });
+ });
+
+ describe('Edge Cases & Date Handling', () => {
+ test('Handles views when hideViews is false', () => {
+ const props = {
+ editLink: '/e',
+ link: '/l',
+ showSelection: true,
+ hideViews: false,
+ views: 100,
+ publish_date: '2020-01-01T00:00:00Z',
+ author_name: 'A',
+ author_link: '/u/a',
+ };
+
+ const { getByTestId } = render();
+ expect(getByTestId('views')).toBeTruthy();
+ expect(getByTestId('views').getAttribute('data-views')).toBe('100');
+ });
+
+ test('Renders without showSelection', () => {
+ const props = {
+ editLink: '/e',
+ link: '/l',
+ showSelection: false,
+ publish_date: '2020-01-01T00:00:00Z',
+ author_name: 'A',
+ author_link: '/u/a',
+ };
+
+ const { queryByTestId } = render();
+ expect(queryByTestId('view')).toBeNull();
+ });
+
+ test('Handles numeric publish_date correctly', () => {
+ const props = {
+ editLink: '/e',
+ link: '/l',
+ showSelection: true,
+ publish_date: 1577836800000, // 2020-01-01 as timestamp
+ author_name: 'A',
+ author_link: '/u/a',
+ };
+
+ const { getByTestId } = render();
+ expect(getByTestId('date')).toBeTruthy();
+ });
+
+ test('Handles empty author_link by setting it to null', () => {
+ const props = {
+ editLink: '/e',
+ link: '/l',
+ showSelection: true,
+ singleLinkContent: false,
+ author_name: 'Anonymous',
+ author_link: '', // Empty link
+ publish_date: '2020-01-01T00:00:00Z',
+ };
+
+ const { container } = render();
+ const authorLink = container.querySelector('[data-testid="author-link"]') as HTMLAnchorElement;
+ expect(authorLink).toBeTruthy();
+ expect(authorLink.getAttribute('href')).toBeNull();
+ });
+ });
+ });
+});
diff --git a/frontend/tests/utils/hooks/usePopup.test.tsx b/frontend/tests/utils/hooks/usePopup.test.tsx
new file mode 100644
index 00000000..92f1ea3e
--- /dev/null
+++ b/frontend/tests/utils/hooks/usePopup.test.tsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+
+// Mock popup components to avoid SCSS imports breaking Jest
+jest.mock('../../../src/static/js/components/_shared/popup/Popup.jsx', () => {
+ const React = require('react');
+ const Popup = React.forwardRef((props: any, _ref: any) => React.createElement('div', props, props.children));
+ return { __esModule: true, default: Popup };
+});
+
+jest.mock('../../../src/static/js/components/_shared/popup/PopupContent.jsx', () => ({
+ PopupContent: (props: any) => React.createElement('div', props, props.children),
+}));
+
+jest.mock('../../../src/static/js/components/_shared/popup/PopupTrigger.jsx', () => ({
+ PopupTrigger: (props: any) => React.createElement('div', props, props.children),
+}));
+
+import { usePopup } from '../../../src/static/js/utils/hooks/usePopup';
+
+describe('utils/hooks', () => {
+ describe('usePopup', () => {
+ test('Returns a 3-tuple: [ref, PopupContent, PopupTrigger]', () => {
+ let value: any;
+
+ const Comp: React.FC = () => {
+ value = usePopup();
+ return null;
+ };
+
+ render();
+
+ expect(Array.isArray(value)).toBe(true);
+ expect(value).toHaveLength(3);
+
+ const [ref, PopupContent, PopupTrigger] = value;
+
+ expect(ref).toBeDefined();
+ expect(ref.current).toBe(null);
+
+ expect(typeof PopupContent).toBe('function');
+ expect(typeof PopupTrigger).toBe('function');
+ });
+
+ test('Tuple components are stable functions and refs are unique per call', () => {
+ let results: any[] = [];
+
+ const Comp: React.FC = () => {
+ results.push(usePopup());
+ results.push(usePopup());
+ return null;
+ };
+
+ render();
+
+ const [ref1, PopupContent1, PopupTrigger1] = results[0];
+ const [ref2, PopupContent2, PopupTrigger2] = results[1];
+
+ expect(typeof PopupContent1).toBe('function');
+ expect(typeof PopupTrigger1).toBe('function');
+
+ expect(PopupContent1).toBe(PopupContent2);
+ expect(PopupTrigger1).toBe(PopupTrigger2);
+
+ expect(ref1).not.toBe(ref2);
+ });
+ });
+});
diff --git a/frontend/tests/utils/hooks/useTheme.test.tsx b/frontend/tests/utils/hooks/useTheme.test.tsx
new file mode 100644
index 00000000..b3d44065
--- /dev/null
+++ b/frontend/tests/utils/hooks/useTheme.test.tsx
@@ -0,0 +1,100 @@
+import React from 'react';
+import { act, render } from '@testing-library/react';
+
+import { useTheme as useThemeHook } from '../../../src/static/js/utils/hooks/useTheme';
+
+import { sampleMediaCMSConfig } from '../../tests-constants';
+
+jest.mock('../../../src/static/js/utils/classes/', () => ({
+ BrowserCache: jest.fn().mockImplementation(() => ({
+ get: jest.fn(),
+ set: jest.fn(),
+ })),
+}));
+
+jest.mock('../../../src/static/js/utils/dispatcher.js', () => ({
+ register: jest.fn(),
+}));
+
+function getRenderers(ThemeProvider: React.FC<{ children: React.ReactNode }>, useTheme: typeof useThemeHook) {
+ const data: { current: any } = { current: undefined };
+
+ const Comp: React.FC = () => {
+ data.current = useTheme();
+ return null;
+ };
+
+ const wrapper: typeof ThemeProvider = ({ children }) => {children};
+
+ return { Comp, wrapper, data };
+}
+
+function getThemeConfig(override?: {
+ logo?: Partial<(typeof sampleMediaCMSConfig.theme)['logo']>;
+ mode?: (typeof sampleMediaCMSConfig.theme)['mode'];
+ switch?: Partial<(typeof sampleMediaCMSConfig.theme)['switch']>;
+}) {
+ const { logo, mode, switch: sw } = override ?? {};
+ const { lightMode, darkMode } = logo ?? {};
+
+ const config = {
+ logo: {
+ lightMode: { img: lightMode?.img ?? '/img/light.png', svg: lightMode?.svg ?? '/img/light.svg' },
+ darkMode: { img: darkMode?.img ?? '/img/dark.png', svg: darkMode?.svg ?? '/img/dark.svg' },
+ },
+ mode: mode ?? 'dark',
+ switch: { enabled: sw?.enabled ?? true, position: sw?.position ?? 'sidebar' },
+ };
+
+ return config;
+}
+
+describe('utils/hooks', () => {
+ afterEach(() => {
+ jest.resetModules();
+ jest.clearAllMocks();
+ });
+
+ describe('useTheme', () => {
+ const themeConfig = getThemeConfig();
+ const darkThemeConfig = getThemeConfig({ mode: 'dark' });
+
+ // @todo: Revisit this
+ test.each([
+ [
+ darkThemeConfig,
+ {
+ logo: darkThemeConfig.logo.darkMode.svg,
+ currentThemeMode: darkThemeConfig.mode,
+ changeThemeMode: expect.any(Function),
+ themeModeSwitcher: themeConfig.switch,
+ },
+ ],
+ ])('Validate value', async (theme, expectedResult) => {
+ jest.doMock('../../../src/static/js/utils/settings/config', () => ({
+ config: jest.fn(() => ({ ...jest.requireActual('../../tests-constants').sampleMediaCMSConfig, theme })),
+ }));
+
+ const { ThemeProvider } = await import('../../../src/static/js/utils/contexts/ThemeContext');
+ const { useTheme } = await import('../../../src/static/js/utils/hooks/useTheme');
+
+ const { Comp, wrapper, data } = getRenderers(ThemeProvider, useTheme);
+
+ render(, { wrapper });
+
+ expect(data.current).toStrictEqual(expectedResult);
+
+ act(() => data.current.changeThemeMode());
+
+ const newThemeMode = 'light' === expectedResult.currentThemeMode ? 'dark' : 'light';
+ const newThemeLogo =
+ 'light' === newThemeMode ? themeConfig.logo.lightMode.svg : themeConfig.logo.darkMode.svg;
+
+ expect(data.current).toStrictEqual({
+ ...expectedResult,
+ logo: newThemeLogo,
+ currentThemeMode: newThemeMode,
+ });
+ });
+ });
+});
diff --git a/frontend/tests/utils/hooks/useUser.test.tsx b/frontend/tests/utils/hooks/useUser.test.tsx
new file mode 100644
index 00000000..ad50f10c
--- /dev/null
+++ b/frontend/tests/utils/hooks/useUser.test.tsx
@@ -0,0 +1,45 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+
+import { UserProvider } from '../../../src/static/js/utils/contexts/UserContext';
+import { useUser } from '../../../src/static/js/utils/hooks/useUser';
+import { sampleMediaCMSConfig } from '../../tests-constants';
+
+jest.mock('../../../src/static/js/utils/settings/config', () => ({
+ config: jest.fn(() => jest.requireActual('../../tests-constants').sampleMediaCMSConfig),
+}));
+
+function getRenderers() {
+ const data: { current: any } = { current: undefined };
+
+ const Comp: React.FC = () => {
+ data.current = useUser();
+ return null;
+ };
+
+ const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {children};
+
+ return { Comp, wrapper, data };
+}
+
+describe('utils/hooks', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('useUser', () => {
+ test('Validate value', () => {
+ const { Comp, wrapper, data } = getRenderers();
+
+ render(, { wrapper });
+
+ expect(data.current).toStrictEqual({
+ isAnonymous: sampleMediaCMSConfig.member.is.anonymous,
+ username: sampleMediaCMSConfig.member.username,
+ thumbnail: sampleMediaCMSConfig.member.thumbnail,
+ userCan: sampleMediaCMSConfig.member.can,
+ pages: sampleMediaCMSConfig.member.pages,
+ });
+ });
+ });
+});