@@ -14,7 +14,7 @@ import {
1414 clearSelectedUserIds ,
1515 setAllUsers ,
1616} from './state' ;
17- import { escapeHtml , formatRelativeTime , formatDate , showSuccess , showError } from './utils' ;
17+ import { escapeHtml , formatRelativeTime , formatDate , showSuccess , showError , validateGroupCombination } from './utils' ;
1818import { openEditUserModal , deleteUser } from './userActions' ;
1919import { ADMINISTRATORS_GROUP_ID } from '../permissions' ;
2020
@@ -210,6 +210,9 @@ function renderUserExpandPanel(user: APIUser): string {
210210/**
211211 * Toggle a group membership for a user inline (no modal).
212212 *
213+ * Before the API call, validates that the resulting group set is not a
214+ * contradictory combination (issue #1405: view-only + write-capable).
215+ *
213216 * After a successful API call, patch state in-memory and refresh only the
214217 * affected expand panel rather than re-rendering the whole table. This
215218 * prevents the panel from collapsing on every toggle (issue #998) and
@@ -226,6 +229,20 @@ async function toggleUserGroup(userId: string, groupId: string, checked: boolean
226229 ? [ ...new Set ( [ ...user . groups , groupId ] ) ]
227230 : user . groups . filter ( g => g !== groupId ) ;
228231
232+ // Validate the resulting group combination before hitting the API.
233+ // A view-only group (all permissions are view:*) combined with a
234+ // write-capable group is semantically contradictory (issue #1405).
235+ const combinationError = validateGroupCombination ( next , availableGroups ) ;
236+ if ( combinationError ) {
237+ showError ( combinationError ) ;
238+ // Revert the checkbox immediately so the DOM reflects reality.
239+ const cb = document . querySelector < HTMLInputElement > (
240+ `.group-assign-checkbox[data-user-id="${ userId } "][data-group-id="${ groupId } "]` ,
241+ ) ;
242+ if ( cb ) cb . checked = ! checked ;
243+ return ;
244+ }
245+
229246 try {
230247 await api . updateUser ( userId , { groups : next } ) ;
231248
@@ -276,6 +293,9 @@ async function toggleUserGroup(userId: string, groupId: string, checked: boolean
276293 */
277294function setupUserTableListeners ( ) : void {
278295 // Select all checkbox
296+ // Patch all rows in-place instead of calling renderUsers(), which would
297+ // tear down and rebuild the entire table and collapse any open expand
298+ // panels (issue #1404 symptom: expand arrow / panel lost on checkbox click).
279299 const selectAllCheckbox = document . getElementById ( 'select-all-users' ) as HTMLInputElement ;
280300 if ( selectAllCheckbox ) {
281301 selectAllCheckbox . addEventListener ( 'change' , ( e ) => {
@@ -285,24 +305,56 @@ function setupUserTableListeners(): void {
285305 } else {
286306 clearSelectedUserIds ( ) ;
287307 }
288- renderUsers ( filteredUsers ) ;
308+ // Patch each row's selected class and its individual checkbox without
309+ // re-rendering the whole table (preserves open expand panels).
310+ filteredUsers . forEach ( user => {
311+ const row = document . querySelector < HTMLElement > (
312+ `tr.user-row[data-user-id="${ user . id } "]` ,
313+ ) ;
314+ const cb = document . querySelector < HTMLInputElement > (
315+ `.user-checkbox[data-user-id="${ user . id } "]` ,
316+ ) ;
317+ if ( row ) {
318+ if ( checked ) row . classList . add ( 'row-selected' ) ;
319+ else row . classList . remove ( 'row-selected' ) ;
320+ }
321+ if ( cb ) cb . checked = checked ;
322+ } ) ;
289323 updateBulkActionsBar ( ) ;
290324 } ) ;
291325 }
292326
293327 // Individual checkboxes
328+ // Same rationale: patch the affected row in-place instead of calling
329+ // renderUsers() so open expand panels are not collapsed (issue #1404).
294330 document . querySelectorAll ( '.user-checkbox' ) . forEach ( checkbox => {
295331 checkbox . addEventListener ( 'change' , ( e ) => {
296332 const userId = ( e . target as HTMLElement ) . dataset . userId ;
297333 if ( ! userId ) return ;
298334
299- if ( ( e . target as HTMLInputElement ) . checked ) {
335+ const nowChecked = ( e . target as HTMLInputElement ) . checked ;
336+ if ( nowChecked ) {
300337 addSelectedUserId ( userId ) ;
301338 } else {
302339 removeSelectedUserId ( userId ) ;
303340 }
304341
305- renderUsers ( filteredUsers ) ;
342+ // Toggle the row-selected class on just the affected row.
343+ const row = document . querySelector < HTMLElement > (
344+ `tr.user-row[data-user-id="${ userId } "]` ,
345+ ) ;
346+ if ( row ) {
347+ if ( nowChecked ) row . classList . add ( 'row-selected' ) ;
348+ else row . classList . remove ( 'row-selected' ) ;
349+ }
350+
351+ // Keep the select-all checkbox in sync.
352+ const selectAll = document . getElementById ( 'select-all-users' ) as HTMLInputElement | null ;
353+ if ( selectAll ) {
354+ selectAll . checked =
355+ selectedUserIds . size > 0 && selectedUserIds . size === filteredUsers . length ;
356+ }
357+
306358 updateBulkActionsBar ( ) ;
307359 } ) ;
308360 } ) ;
0 commit comments