Skip to content
Open
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
7 changes: 7 additions & 0 deletions web/includes/View/AdminGroupsListView.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ final class AdminGroupsListView extends View
* and `api_groups_edit` ignores the field for type=web, so the
* master-detail editor never surfaces it; SourceMod admin groups
* (`:prefix_srvgroups`) keep their immunity surface elsewhere.
* @param string $web_groups_catalog_json JSON array of
* `{gid, name, flags, member_count}` for every web group. The
* master-detail client paints the right pane from this catalog
* on left-rail clicks so selecting a group does not require a
* full page navigation. Empty array string `"[]"` when the
* directory is empty.
*/
public function __construct(
public readonly bool $permission_listgroups,
Expand All @@ -103,6 +109,7 @@ public function __construct(
public readonly array $server_list,
public readonly array $all_flags,
public readonly ?array $selected_group,
public readonly string $web_groups_catalog_json = '[]',
) {
}
}
61 changes: 59 additions & 2 deletions web/pages/admin.edit.group.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@

global $userbank, $theme;

new \Sbpp\View\AdminTabs([], $userbank, $theme);

require_once __DIR__ . '/_admin_edit_helpers.php';

$groupId = isset($_GET['id']) ? (int) $_GET['id'] : 0;
Expand Down Expand Up @@ -192,13 +190,71 @@ function syncParent() {
syncParent();
});

function scopeBoxes(scope) {
var table = form.querySelector('table[data-perms-scope="' + scope + '"]');
if (!table) return [];
if (scope === 'server') {
return Array.prototype.slice.call(table.querySelectorAll('input[data-sm-flag]'));
}
return Array.prototype.slice.call(table.querySelectorAll('input[type="checkbox"]'));
}

function syncSelectAll(scope) {
var master = form.querySelector('input[data-select-all="' + scope + '"]');
if (!master) return;
var boxes = scopeBoxes(scope);
var on = 0;
for (var i = 0; i < boxes.length; i++) {
if (boxes[i].checked) on++;
}
master.checked = boxes.length > 0 && on === boxes.length;
master.indeterminate = on > 0 && on < boxes.length;
}

function wireSelectAll(scope) {
var master = form.querySelector('input[data-select-all="' + scope + '"]');
if (!master) return;
master.addEventListener('change', function () {
var on = master.checked;
scopeBoxes(scope).forEach(function (c) { c.checked = on; });
if (scope === 'web') {
form.querySelectorAll('input[data-parent]').forEach(function (parent) {
var name = parent.getAttribute('data-parent');
var children = form.querySelectorAll('input[data-child="' + name + '"]');
var anyOn = false;
for (var i = 0; i < children.length; i++) {
if (children[i].checked) { anyOn = true; break; }
}
parent.checked = anyOn;
});
}
master.indeterminate = false;
syncSelectAll(scope);
});
syncSelectAll(scope);
}

wireSelectAll('web');
wireSelectAll('server');

form.addEventListener('change', function (e) {
var t = e.target;
if (!t || t.type !== 'checkbox' || t.hasAttribute('data-select-all')) return;
var table = t.closest('table[data-perms-scope]');
if (!table) return;
syncSelectAll(table.getAttribute('data-perms-scope'));
});

var ownerCb = document.getElementById('p2');
if (ownerCb) {
ownerCb.addEventListener('change', function () {
if (!ownerCb.checked) return;
form.querySelectorAll('input[data-child], input[data-parent]').forEach(function (c) {
c.checked = true;
});
var settingsCb = document.getElementById('p26');
if (settingsCb) settingsCb.checked = true;
syncSelectAll('web');
});
}

Expand All @@ -209,6 +265,7 @@ function syncParent() {
form.querySelectorAll('input[data-sm-flag]').forEach(function (c) {
if (c !== smRootCb) c.checked = true;
});
syncSelectAll('server');
});
}

Expand Down
20 changes: 20 additions & 0 deletions web/pages/admin.groups.php
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,25 @@
];
}

$web_groups_catalog = [];
foreach ($web_group_list as $g) {
$web_groups_catalog[] = [
'gid' => (int) $g['gid'],
'name' => (string) $g['name'],
'flags' => (int) $g['flags'],
'member_count' => (int) $g['member_count'],
];
}
$web_groups_catalog_json = json_encode(
$web_groups_catalog,
JSON_THROW_ON_ERROR
| JSON_HEX_TAG
| JSON_HEX_AMP
| JSON_HEX_APOS
| JSON_HEX_QUOT
| JSON_INVALID_UTF8_SUBSTITUTE,
);

\Sbpp\View\Renderer::render($theme, new \Sbpp\View\AdminGroupsListView(
permission_listgroups: $canList,
permission_editgroup: $userbank->HasAccess(WebPermission::mask(WebPermission::Owner, WebPermission::EditGroups)),
Expand All @@ -281,6 +300,7 @@
server_list: $server_list,
all_flags: $all_flags,
selected_group: $selected_group,
web_groups_catalog_json: $web_groups_catalog_json,
));

?>
Expand Down
96 changes: 96 additions & 0 deletions web/tests/e2e/specs/flows/admin-groups-client-select.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Flow spec — Web Admin Groups master-detail selection paints the
* right pane from `#web-groups-catalog` without a full page reload.
*
* Selectors
* ---------
* - `[data-testid="group-row"]`
* - `[data-testid="group-detail"]`
* - `[data-testid="group-detail-name"]`
* - `[data-testid="web-groups-catalog"]`
*/

import { expect, test } from '../../fixtures/auth.ts';
import { truncateE2eDb } from '../../fixtures/db.ts';

const GROUPS_LIST_ROUTE = '/index.php?p=admin&c=groups&section=list';

const FIXTURE = {
groupA: 'e2e-select-group-a',
groupB: 'e2e-select-group-b',
};

async function seedWebGroup(page: import('@playwright/test').Page, name: string): Promise<void> {
const envelope = await page.evaluate(async (groupName) => {
const w = window as unknown as {
sb: {
api: {
call: (
action: string,
params: Record<string, unknown>,
) => Promise<{
ok: boolean;
error?: { code: string; message: string };
}>;
};
};
Actions: Record<string, string>;
};
return await w.sb.api.call(w.Actions.GroupsAdd, {
name: groupName,
type: '1',
bitmask: 0,
srvflags: '',
});
}, name);

expect(envelope.ok, `groups.add must succeed: ${JSON.stringify(envelope)}`).toBe(true);
}

test.describe('flow: admin groups client-side selection', () => {
test.skip(({ isMobile }) => isMobile, 'flow spec runs only on desktop chromium');

test.beforeEach(async () => {
await truncateE2eDb();
});

test('clicking another web group paints the detail pane without reloading', async ({
page,
}) => {
await page.goto('/');
await seedWebGroup(page, FIXTURE.groupA);
await seedWebGroup(page, FIXTURE.groupB);

await page.goto(GROUPS_LIST_ROUTE);

await expect(page.locator('[data-testid="web-groups-catalog"]')).toBeAttached();
await expect(page.locator('[data-testid="group-detail"]')).toBeVisible();

const rowA = page.locator('[data-testid="group-row"]').filter({ hasText: FIXTURE.groupA });
const rowB = page.locator('[data-testid="group-row"]').filter({ hasText: FIXTURE.groupB });
await expect(rowA).toHaveCount(1);
await expect(rowB).toHaveCount(1);

await page.evaluate(() => {
(window as unknown as { __sbppClientSelectMarker?: boolean }).__sbppClientSelectMarker =
true;
});

await rowB.click();

await expect(page.locator('[data-testid="group-detail-name"]')).toHaveText(FIXTURE.groupB);
await expect(page).toHaveURL(/[?&]gid=\d+/);
await expect(rowB).toHaveAttribute('aria-current', 'true');

const stayed = await page.evaluate(
() =>
(window as unknown as { __sbppClientSelectMarker?: boolean })
.__sbppClientSelectMarker === true,
);
expect(stayed, 'clicking a group-row must not full-reload the document').toBe(true);

await rowA.click();
await expect(page.locator('[data-testid="group-detail-name"]')).toHaveText(FIXTURE.groupA);
await expect(rowA).toHaveAttribute('aria-current', 'true');
});
});
4 changes: 3 additions & 1 deletion web/tests/e2e/specs/flows/admin-groups-delete.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,9 @@ test.describe('flow: admin groups delete (#1310 — applyApiResponse zombie)', (
response.status() === 200,
);

await detail.locator('[data-testid="group-delete"]').click();
const deleteBtn = detail.locator('[data-testid="group-delete"]');
await expect(deleteBtn.locator('[data-lucide="trash-2"], svg.lucide-trash-2')).toBeVisible();
await deleteBtn.click();

const deleteResponse = await deleteResponsePromise;
const deleteEnvelope = await deleteResponse.json();
Expand Down
52 changes: 52 additions & 0 deletions web/tests/integration/EditGroupChromeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php
// SourceBans++ (c) 2014-2026 SourceBans++ Dev Team
// Licensed under the Elastic License 2.0.
// See LICENSE.txt for the full license text and THIRD-PARTY-NOTICES.txt for attributions.

declare(strict_types=1);

namespace Sbpp\Tests\Integration;

use PHPUnit\Framework\TestCase;

/**
* Pins the edit-group permissions chrome to the same `.edit-perms-*` /
* `.perms-group-*` vocabulary as edit-admin-permissions.
*/
final class EditGroupChromeTest extends TestCase
{
public function testEditGroupTemplateUsesSharedPermsChrome(): void
{
$src = (string) file_get_contents(ROOT . 'themes/default/page_admin_edit_group.tpl');

$this->assertStringContainsString('class="card-tab page-section"', $src);
$this->assertStringContainsString('edit-perms-select-all', $src);
$this->assertStringContainsString('data-testid="edit-group-web-select-all"', $src);
$this->assertStringContainsString('data-testid="edit-group-server-select-all"', $src);
$this->assertStringContainsString('data-perms-scope="web"', $src);
$this->assertStringContainsString('data-perms-scope="server"', $src);
$this->assertStringContainsString('perms-group-head', $src);
$this->assertStringContainsString('perms-group-child', $src);
$this->assertStringContainsString('perms-group-sep', $src);
$this->assertStringContainsString('table--compact', $src);
$this->assertStringContainsString('data-testid="edit-group-back"', $src);
$this->assertStringNotContainsString('class="pl-6"', $src);
}

public function testEditGroupHandlerSkipsOrphanBackStripAndWiresSelectAll(): void
{
$src = (string) file_get_contents(ROOT . 'pages/admin.edit.group.php');

$this->assertStringNotContainsString(
'new \\Sbpp\\View\\AdminTabs([],',
$src,
);
$this->assertStringNotContainsString(
'new AdminTabs([],',
$src,
);
$this->assertStringContainsString('wireSelectAll', $src);
$this->assertStringContainsString("wireSelectAll('web')", $src);
$this->assertStringContainsString("wireSelectAll('server')", $src);
}
}
61 changes: 61 additions & 0 deletions web/tests/integration/WebGroupsCatalogTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php
// SourceBans++ (c) 2014-2026 SourceBans++ Dev Team
// Licensed under the Elastic License 2.0.
// See LICENSE.txt for the full license text and THIRD-PARTY-NOTICES.txt for attributions.

declare(strict_types=1);

namespace Sbpp\Tests\Integration;

use PHPUnit\Framework\TestCase;
use ReflectionClass;
use Sbpp\View\AdminGroupsListView;

/**
* Pins the client-side master-detail catalog contract: the View
* carries a JSON blob, the list template emits it, and the page
* handler builds the payload from `$web_group_list`.
*/
final class WebGroupsCatalogTest extends TestCase
{
public function testAdminGroupsListViewExposesCatalogJsonProperty(): void
{
$ref = new ReflectionClass(AdminGroupsListView::class);
$this->assertTrue(
$ref->hasProperty('web_groups_catalog_json'),
'AdminGroupsListView must expose web_groups_catalog_json for the client selector',
);
}

public function testGroupsListTemplateEmitsCatalogAndClientSelector(): void
{
$src = (string) file_get_contents(ROOT . 'themes/default/page_admin_groups_list.tpl');

$this->assertStringContainsString('id="web-groups-catalog"', $src);
$this->assertStringContainsString('data-testid="web-groups-catalog"', $src);
$this->assertStringContainsString('{$web_groups_catalog_json nofilter}', $src);
$this->assertStringContainsString('data-testid="group-detail-name"', $src);
$this->assertStringContainsString('data-testid="group-detail-members"', $src);
$this->assertStringContainsString('history.pushState', $src);
$this->assertStringContainsString('Discard unsaved changes', $src);
}

public function testGroupsListHandlerBuildsCatalogJson(): void
{
$src = php_strip_whitespace(ROOT . 'pages/admin.groups.php');

$this->assertStringContainsString('$web_groups_catalog', $src);
$this->assertStringContainsString('web_groups_catalog_json', $src);
$this->assertStringContainsString('JSON_HEX_TAG', $src);
$this->assertStringContainsString('JSON_THROW_ON_ERROR', $src);
}

public function testSpaceY2UtilityExistsForServerGroupTiles(): void
{
$css = (string) file_get_contents(ROOT . 'themes/default/css/theme.css');
$this->assertStringContainsString(
'.space-y-2 > * + * { margin-top: 0.5rem; }',
$css,
);
}
}
2 changes: 1 addition & 1 deletion web/themes/default/css/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -2199,7 +2199,7 @@ details.queue-row > summary > .row-actions {

.m-0 { margin: 0; } .mt-2 { margin-top: 0.5rem; } .mt-4 { margin-top: 1rem; } .mt-6 { margin-top: 1.5rem; }
.mb-2 { margin-bottom: 0.5rem; } .mb-4 { margin-bottom: 1rem; } .mb-6 { margin-bottom: 1.5rem; }
.space-y-3 > * + * { margin-top: 0.75rem; } .space-y-4 > * + * { margin-top: 1rem; } .space-y-6 > * + * { margin-top: 1.5rem; }
.space-y-2 > * + * { margin-top: 0.5rem; } .space-y-3 > * + * { margin-top: 0.75rem; } .space-y-4 > * + * { margin-top: 1rem; } .space-y-6 > * + * { margin-top: 1.5rem; }

/* Hide scrollbars on chip rows */
.scroll-x { overflow-x: auto; scrollbar-width: none; }
Expand Down
Loading
Loading