diff --git a/frontend/src/__tests__/users.test.ts b/frontend/src/__tests__/users.test.ts index 31f13241f..4a952e644 100644 --- a/frontend/src/__tests__/users.test.ts +++ b/frontend/src/__tests__/users.test.ts @@ -2069,3 +2069,159 @@ describe('users module integration', () => { expect(bar?.classList.contains('hidden')).toBe(false); }); }); + +// ============================================================================ +// BULK TOOLBAR TESTS (issue #974) +// ============================================================================ +describe('users bulk-actions toolbar', () => { + const mockGroups = [ + { id: 'admins', name: 'Admins', permissions: [], description: '', allowed_accounts: [] }, + { id: 'developers', name: 'Developers', permissions: [], description: '', allowed_accounts: [] }, + ]; + + function buildToolbarDom(): void { + document.body.innerHTML = ` +
+ + + + + +
+
+ + `; + } + + beforeEach(() => { + buildToolbarDom(); + userState.setAllUsers([]); + userState.setFilteredUsers([]); + userState.setAvailableGroups(mockGroups as any); + userState.clearSelectedUserIds(); + jest.clearAllMocks(); + }); + + describe('toolbar visibility', () => { + it('should be hidden when no users are selected', () => { + userList.updateBulkActionsBar(); + + const bar = document.getElementById('bulk-actions-bar'); + expect(bar?.classList.contains('hidden')).toBe(true); + }); + + it('should be visible with correct count when one user is selected', () => { + userState.addSelectedUserId('user-1'); + + userList.updateBulkActionsBar(); + + const bar = document.getElementById('bulk-actions-bar'); + expect(bar?.classList.contains('hidden')).toBe(false); + const count = document.getElementById('selected-count'); + expect(count?.textContent).toBe('1'); + }); + + it('should be visible with correct count when multiple users are selected', () => { + userState.addSelectedUserId('user-1'); + userState.addSelectedUserId('user-2'); + userState.addSelectedUserId('user-3'); + + userList.updateBulkActionsBar(); + + const bar = document.getElementById('bulk-actions-bar'); + expect(bar?.classList.contains('hidden')).toBe(false); + const count = document.getElementById('selected-count'); + expect(count?.textContent).toBe('3'); + }); + + it('should become hidden again after all rows are unchecked', () => { + userState.addSelectedUserId('user-1'); + userList.updateBulkActionsBar(); + + const barBefore = document.getElementById('bulk-actions-bar'); + expect(barBefore?.classList.contains('hidden')).toBe(false); + + userState.clearSelectedUserIds(); + userList.updateBulkActionsBar(); + + const barAfter = document.getElementById('bulk-actions-bar'); + expect(barAfter?.classList.contains('hidden')).toBe(true); + }); + }); + + describe('bulk-delete-btn', () => { + it('should call bulkDeleteUsers when clicked via setupUserHandlers', async () => { + const mockUser = { id: '1', email: 'u@t.com', role: 'user', groups: [], mfa_enabled: false }; + userState.setAllUsers([mockUser] as any); + userState.addSelectedUserId('1'); + + (api.deleteUser as jest.Mock).mockResolvedValue({}); + (api.listUsers as jest.Mock).mockResolvedValue({ users: [] }); + (api.listGroups as jest.Mock).mockResolvedValue({ groups: [] }); + + userHandlers.setupUserHandlers(); + + const bulkDeleteBtn = document.getElementById('bulk-delete-btn'); + bulkDeleteBtn?.click(); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(api.deleteUser).toHaveBeenCalledWith('1'); + }); + }); + + describe('bulk-group-select', () => { + it('should populate with available groups', () => { + userList.populateBulkGroupSelect(); + + const select = document.getElementById('bulk-group-select') as HTMLSelectElement; + // placeholder + 2 groups + expect(select.options.length).toBe(3); + expect(select.options[1]?.value).toBe('admins'); + expect(select.options[1]?.text).toBe('Admins'); + expect(select.options[2]?.value).toBe('developers'); + expect(select.options[2]?.text).toBe('Developers'); + }); + + it('should call bulkAddToGroup with selected group id on change via setupUserHandlers', async () => { + const mockUser = { id: '1', email: 'test@test.com', role: 'user', groups: [], mfa_enabled: false }; + userState.setAllUsers([mockUser] as any); + userState.addSelectedUserId('1'); + + (api.updateUser as jest.Mock).mockResolvedValue({}); + (api.listUsers as jest.Mock).mockResolvedValue({ users: [mockUser] }); + (api.listGroups as jest.Mock).mockResolvedValue({ groups: mockGroups }); + + userHandlers.setupUserHandlers(); + + const select = document.getElementById('bulk-group-select') as HTMLSelectElement; + select.value = 'admins'; + select.dispatchEvent(new Event('change', { bubbles: true })); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(api.updateUser).toHaveBeenCalledWith('1', { groups: ['admins'] }); + }); + + it('should not call bulkAddToGroup when placeholder is selected', async () => { + userState.addSelectedUserId('1'); + (api.updateUser as jest.Mock).mockResolvedValue({}); + + userHandlers.setupUserHandlers(); + + const select = document.getElementById('bulk-group-select') as HTMLSelectElement; + select.value = ''; + select.dispatchEvent(new Event('change', { bubbles: true })); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(api.updateUser).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/src/index.html b/frontend/src/index.html index 36b825be9..c3a899706 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -701,6 +701,13 @@

User Management

+
diff --git a/frontend/src/users/handlers.ts b/frontend/src/users/handlers.ts index 5a852f8b4..635af3d31 100644 --- a/frontend/src/users/handlers.ts +++ b/frontend/src/users/handlers.ts @@ -11,6 +11,7 @@ import { availableGroups } from './state'; import { handleUserSearch, handleFilterChange, clearFilters, updateGroupFilterDropdown } from './filters'; import { openCreateUserModal, closeUserModal, saveUser } from './userModals'; import { bulkDeleteUsers, bulkAddToGroup } from './userActions'; +import { populateBulkGroupSelect } from './userList'; /** * Setup event handlers for user management @@ -83,6 +84,21 @@ export function setupUserHandlers(): void { }); } - // Populate group filter dropdown + // Bulk group select dropdown (replaces the prompt on the production toolbar) + const bulkGroupSelect = document.getElementById('bulk-group-select') as HTMLSelectElement | null; + if (bulkGroupSelect) { + bulkGroupSelect.addEventListener('change', () => { + const groupId = bulkGroupSelect.value; + if (groupId) { + void bulkAddToGroup(groupId).then(() => { + // Reset to placeholder after the operation completes + bulkGroupSelect.value = ''; + }); + } + }); + } + + // Populate both group dropdowns updateGroupFilterDropdown(); + populateBulkGroupSelect(); } diff --git a/frontend/src/users/index.ts b/frontend/src/users/index.ts index 52d952af1..b4732d17c 100644 --- a/frontend/src/users/index.ts +++ b/frontend/src/users/index.ts @@ -18,7 +18,7 @@ export { formatRelativeTime, escapeHtml, showError, showSuccess } from './utils' export { applyFilters, handleUserSearch, handleFilterChange, clearFilters, updateGroupFilterDropdown } from './filters'; // Re-export user list rendering -export { renderUsers, renderUserStats, updateBulkActionsBar } from './userList'; +export { renderUsers, renderUserStats, updateBulkActionsBar, populateBulkGroupSelect } from './userList'; // Re-export user modals export { openCreateUserModal, openEditUserModal, closeUserModal, saveUser } from './userModals'; diff --git a/frontend/src/users/userActions.ts b/frontend/src/users/userActions.ts index 7a87f6d95..0e1eda65b 100644 --- a/frontend/src/users/userActions.ts +++ b/frontend/src/users/userActions.ts @@ -16,7 +16,7 @@ import { import { showError, showSuccess } from './utils'; import { confirmDialog } from '../confirmDialog'; import { applyFilters } from './filters'; -import { renderUsers, renderUserStats } from './userList'; +import { renderUsers, renderUserStats, populateBulkGroupSelect } from './userList'; import { renderGroups } from '../groups/groupList'; import { renderPermissionMatrix } from './permissionMatrix'; @@ -90,6 +90,7 @@ export async function loadUsers(): Promise { renderUsers(filteredUsers); renderGroups(groups); renderUserStats(); + populateBulkGroupSelect(); const matrixContainer = document.getElementById('permission-matrix'); if (matrixContainer) { diff --git a/frontend/src/users/userList.ts b/frontend/src/users/userList.ts index 2ee3e1656..e1e71acaf 100644 --- a/frontend/src/users/userList.ts +++ b/frontend/src/users/userList.ts @@ -328,7 +328,7 @@ function setupUserTableListeners(): void { } /** - * Update bulk actions bar + * Update bulk actions bar visibility and selection count. */ export function updateBulkActionsBar(): void { const bulkBar = document.getElementById('bulk-actions-bar'); @@ -344,3 +344,19 @@ export function updateBulkActionsBar(): void { if (countEl) countEl.textContent = selectedCount.toString(); } } + +/** + * Populate the #bulk-group-select dropdown with the currently loaded groups. + * Called by loadUsers after groups are refreshed so the options stay in sync. + */ +export function populateBulkGroupSelect(): void { + const select = document.getElementById('bulk-group-select') as HTMLSelectElement | null; + if (!select) return; + while (select.options.length > 1) select.remove(1); + for (const group of availableGroups) { + const opt = document.createElement('option'); + opt.value = group.id; + opt.textContent = group.name; + select.add(opt); + } +}