Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions frontend/src/__tests__/users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<form id="user-form"></form>
<input type="text" id="user-search" />
<select id="user-role-filter"><option value="">All</option></select>
<select id="user-mfa-filter"><option value="">All</option></select>
<select id="user-group-filter"><option value="">All Groups</option></select>
<button id="clear-filters-btn">Clear</button>
<div id="users-list"></div>
<div id="user-stats"></div>
<div id="bulk-actions-bar" class="hidden">
<span id="selected-count">0</span>
<button id="bulk-delete-btn">Delete selected</button>
<select id="bulk-group-select">
<option value="">Add to group...</option>
</select>
</div>
`;
}

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();
});
});
});
7 changes: 7 additions & 0 deletions frontend/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,13 @@ <h2>User Management</h2>
<div class="section-header">
<button type="button" id="create-user-btn" class="primary">Create User</button>
</div>
<div id="bulk-actions-bar" class="hidden bulk-actions-bar">
<span class="bulk-actions-count"><span id="selected-count">0</span> users selected</span>
<button type="button" id="bulk-delete-btn" class="btn btn-danger btn-small">Delete selected</button>
<select id="bulk-group-select" aria-label="Add selected users to group">
<option value="">Add to group...</option>
</select>
</div>
<div id="users-list" class="mt-3"></div>
</fieldset>

Expand Down
18 changes: 17 additions & 1 deletion frontend/src/users/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
}
2 changes: 1 addition & 1 deletion frontend/src/users/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/users/userActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -90,6 +90,7 @@ export async function loadUsers(): Promise<void> {
renderUsers(filteredUsers);
renderGroups(groups);
renderUserStats();
populateBulkGroupSelect();

const matrixContainer = document.getElementById('permission-matrix');
if (matrixContainer) {
Expand Down
18 changes: 17 additions & 1 deletion frontend/src/users/userList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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);
}
}
Loading