From 5e014b9f9d9ef27d3112e398277f9c4d5bacd2b4 Mon Sep 17 00:00:00 2001 From: Rushaway Date: Thu, 3 Sep 2026 10:54:59 +0200 Subject: [PATCH 1/2] feat(groups): add Select all / Select none to web group permission flags Porting upstream issue sbpp/sourcebans-pp#1436: operators had to tick every web-admin-group permission flag individually. Adds two ghost buttons next to the "Permission flags" label on the groups list master-detail editor that bulk-toggle every enabled checkbox in the flag grid and refresh the live bitmask preview. The folded OR-sum round-trips through Save unchanged. Buttons are gated on permission_editgroup, matching the checkboxes. Co-Authored-By: Claude Sonnet 5 --- .../admin-groups-select-all-flags.spec.ts | 112 ++++++++++++++++++ web/themes/default/page_admin_groups_list.tpl | 47 +++++++- 2 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 web/tests/e2e/specs/flows/admin-groups-select-all-flags.spec.ts diff --git a/web/tests/e2e/specs/flows/admin-groups-select-all-flags.spec.ts b/web/tests/e2e/specs/flows/admin-groups-select-all-flags.spec.ts new file mode 100644 index 000000000..f1f9b167d --- /dev/null +++ b/web/tests/e2e/specs/flows/admin-groups-select-all-flags.spec.ts @@ -0,0 +1,112 @@ +/** + * Flow spec — upstream issue sbpp/sourcebans-pp#1436: the web admin + * groups permission flag grid needs a bulk toggle so operators don't + * have to tick every permission checkbox individually. + * + * What this locks in + * ------------------ + * `?p=admin&c=groups§ion=list` renders the master-detail flag grid + * (one checkbox per web-permission flag). Two buttons next to the + * "Permission flags" label: + * - `[data-testid="flag-select-all"]` — checks every enabled flag + * - `[data-testid="flag-select-none"]` — unchecks every flag + * Both refresh the live `[data-testid="flag-bitmask"]` preview, and the + * folded OR-sum round-trips through Save exactly like a manual toggle. + * + * Project gating mirrors `admin-groups-bitmask.spec.ts` (desktop + * chromium only; the spec mutates the shared e2e DB). + */ + +import { expect, test } from '../../fixtures/auth.ts'; +import { truncateE2eDb } from '../../fixtures/db.ts'; + +const GROUPS_LIST_ROUTE = '/index.php?p=admin&c=groups§ion=list'; + +const FIXTURE = { + groupName: 'e2e-select-all-flags-group', +}; + +test.describe('flow: admin groups select-all permission flags (upstream #1436)', () => { + test.skip(({ isMobile }) => isMobile, 'flow spec runs only on desktop chromium'); + + test.beforeEach(async () => { + await truncateE2eDb(); + }); + + test('Select all / Select none toggle the whole flag grid', async ({ page }) => { + await page.goto('/'); + + const seedEnvelope = await page.evaluate(async (groupName) => { + const w = window as unknown as { + sb: { + api: { + call: ( + action: string, + params: Record, + ) => Promise<{ ok: boolean; error?: { code: string; message: string } }>; + }; + }; + Actions: Record; + }; + return await w.sb.api.call(w.Actions.GroupsAdd, { + name: groupName, + type: '1', + bitmask: 0, + srvflags: '', + }); + }, FIXTURE.groupName); + + expect(seedEnvelope.ok, `groups.add must succeed: ${JSON.stringify(seedEnvelope)}`).toBe(true); + + await page.goto(GROUPS_LIST_ROUTE); + + const detail = page.locator('[data-testid="group-detail"]'); + await expect(detail).toBeVisible(); + + const flagGrid = detail.locator('[data-testid="flag-grid"]'); + const bitmaskBadge = detail.locator('[data-testid="flag-bitmask"]'); + const selectAll = detail.locator('[data-testid="flag-select-all"]'); + const selectNone = detail.locator('[data-testid="flag-select-none"]'); + const checkboxes = flagGrid.locator('input[name="flags[]"]'); + + await expect(flagGrid).toBeVisible(); + await expect(selectAll).toBeVisible(); + await expect(bitmaskBadge).toHaveText(/^0 bitmask$/); + + const total = await checkboxes.count(); + expect(total).toBeGreaterThan(0); + + // ---- Select all → every checkbox checked, badge non-zero ---------- + await selectAll.click(); + for (let i = 0; i < total; i++) { + await expect(checkboxes.nth(i)).toBeChecked(); + } + await expect(bitmaskBadge).not.toHaveText(/^0 bitmask$/); + await expect(bitmaskBadge).not.toContainText('-'); + + // ---- Save round-trips the folded OR-sum -------------------------- + const saveButton = detail.locator('[data-testid="group-save"]'); + const editResponsePromise = page.waitForResponse( + (response) => + response.url().includes('api.php') && + response.request().method() === 'POST' && + response.status() === 200, + ); + await saveButton.click(); + const editEnvelope = await (await editResponsePromise).json(); + expect(editEnvelope.ok, `groups.edit must succeed: ${JSON.stringify(editEnvelope)}`).toBe(true); + + await page.goto(GROUPS_LIST_ROUTE); + const reloadedGrid = page.locator('[data-testid="flag-grid"]'); + await expect(reloadedGrid.locator('input[name="flags[]"]').first()).toBeChecked(); + + // ---- Select none → every checkbox cleared, badge back to 0 ------- + await page.locator('[data-testid="flag-select-none"]').click(); + const reloadedChecks = reloadedGrid.locator('input[name="flags[]"]'); + const reloadedTotal = await reloadedChecks.count(); + for (let i = 0; i < reloadedTotal; i++) { + await expect(reloadedChecks.nth(i)).not.toBeChecked(); + } + await expect(page.locator('[data-testid="flag-bitmask"]')).toHaveText(/^0 bitmask$/); + }); +}); diff --git a/web/themes/default/page_admin_groups_list.tpl b/web/themes/default/page_admin_groups_list.tpl index 10d1c22ae..dcdace088 100644 --- a/web/themes/default/page_admin_groups_list.tpl +++ b/web/themes/default/page_admin_groups_list.tpl @@ -139,11 +139,26 @@
- {* #1258: `data-testid="flag-bitmask"` lets the page-tail JS - below (and future E2E specs) anchor on the contract instead - of visible copy. SSR is the source of truth for the initial - paint; the listener re-folds the OR-sum on each `change`. *} - {$selected_group.flags} bitmask +
+ {* #1436: bulk toggle for the flag grid so operators don't + have to tick every permission individually. Gated on + `permission_editgroup` like the checkboxes themselves. *} + {if $permission_editgroup} + + + {/if} + {* #1258: `data-testid="flag-bitmask"` lets the page-tail JS + below (and future E2E specs) anchor on the contract instead + of visible copy. SSR is the source of truth for the initial + paint; the listener re-folds the OR-sum on each `change`. *} + {$selected_group.flags} bitmask +
{* #1258: per-flag rows are bare `