re-design the menu builder page
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Successful in 1m24s

This commit is contained in:
root
2026-09-06 21:29:38 -04:00
parent 485341875a
commit ede7fd947a
4 changed files with 1441 additions and 390 deletions
+286 -28
View File
@@ -22,33 +22,14 @@ class NavBuilderController extends BaseController
protected function ensureAdmin(): void
{
$sessionRole = session()->get('role'); // could be a string or array in your app
$roleNames = is_array($sessionRole) ? $sessionRole : [$sessionRole];
$roleNames = array_values(array_filter(array_map('strval', $roleNames)));
$session = session();
$roles = array_filter(array_merge(
(array) $session->get('roles'),
(array) $session->get('role')
));
if (empty($roleNames)) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
$db = \Config\Database::connect();
// Map role names -> ids
$roleIdRows = $db->table('roles')->select('id')->whereIn('name', $roleNames)->get()->getResultArray();
$roleIds = array_map('intval', array_column($roleIdRows, 'id'));
if (empty($roleIds)) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
// Is this route allowed for any of the user's roles?
$allowed = $db->table('role_nav_items AS rni')
->select('1')
->join('nav_items AS ni', 'ni.id = rni.nav_item_id')
->where('ni.url', 'nav-builder') // IMPORTANT: your current route path
->whereIn('rni.role_id', $roleIds)
->get(1)->getFirstRow();
if (!$allowed) {
// You can show a nicer "Access Denied" view if you prefer
$normalizedRoles = array_map(static fn ($role) => strtolower(trim((string) $role)), $roles);
if (!in_array('administrator', $normalizedRoles, true)) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
}
@@ -106,6 +87,15 @@ public function save()
if ($menuParentId !== null) {
$parent = $this->items->select('id')->where('id', $menuParentId)->first();
if (!$parent) {
if ($this->wantsJson()) {
return $this->response
->setStatusCode(422)
->setJSON([
'ok' => false,
'message' => 'Selected parent does not exist.',
'csrf' => $this->csrfPayload(),
]);
}
return redirect()->back()->with('error', 'Selected parent does not exist.')->withInput();
}
}
@@ -139,9 +129,33 @@ public function save()
}
$this->service->clearCache();
if ($this->wantsJson()) {
return $this->response->setJSON([
'ok' => true,
'message' => 'Menu saved.',
'id' => $id,
'csrf' => $this->csrfPayload(),
'payload' => $this->buildNavPayload(),
]);
}
return redirect()->back()->with('success', 'Menu saved.');
}
private function wantsJson(): bool
{
return $this->request->isAJAX()
|| str_contains(strtolower($this->request->getHeaderLine('Accept')), 'application/json');
}
private function csrfPayload(): array
{
return [
'name' => csrf_token(),
'hash' => csrf_hash(),
];
}
public function delete($id)
{
@@ -158,12 +172,182 @@ public function save()
{
$this->ensureAdmin();
$structure = $this->request->getPost('structure') ?? [];
if (is_array($structure) && !empty($structure)) {
$updates = $this->normalizeStructureUpdates($structure);
foreach ($updates as $row) {
$this->items->update($row['id'], [
'menu_parent_id' => $row['menu_parent_id'],
'sort_order' => $row['sort_order'],
]);
}
$this->service->clearCache();
return $this->response->setJSON(['ok' => true, 'csrf' => $this->csrfPayload()]);
}
// Backward-compatible payload used by the previous builder.
$orders = $this->request->getPost('orders') ?? [];
foreach ($orders as $id => $order) {
$this->items->update((int) $id, ['sort_order' => (int) $order]);
}
$this->service->clearCache();
return $this->response->setJSON(['ok' => true]);
return $this->response->setJSON(['ok' => true, 'csrf' => $this->csrfPayload()]);
}
public function roleAccess()
{
$this->ensureAdmin();
$action = strtolower(trim((string) $this->request->getPost('action')));
$navItemId = (int) $this->request->getPost('nav_item_id');
$sourceRoleId = (int) $this->request->getPost('source_role_id');
$targetRoleId = (int) $this->request->getPost('target_role_id');
if ($navItemId <= 0 || !$this->items->select('id')->find($navItemId)) {
return $this->response
->setStatusCode(422)
->setJSON(['ok' => false, 'message' => 'Selected page does not exist.', 'csrf' => $this->csrfPayload()]);
}
if ($action === 'remove') {
if (!$this->roleExists($sourceRoleId)) {
return $this->response
->setStatusCode(422)
->setJSON(['ok' => false, 'message' => 'Selected role does not exist.', 'csrf' => $this->csrfPayload()]);
}
$this->maps
->where('nav_item_id', $navItemId)
->where('role_id', $sourceRoleId)
->delete();
} elseif ($action === 'move') {
if (!$this->roleExists($sourceRoleId) || !$this->roleExists($targetRoleId)) {
return $this->response
->setStatusCode(422)
->setJSON(['ok' => false, 'message' => 'Selected role does not exist.', 'csrf' => $this->csrfPayload()]);
}
if ($sourceRoleId !== $targetRoleId) {
$this->maps
->where('nav_item_id', $navItemId)
->where('role_id', $sourceRoleId)
->delete();
$exists = $this->maps
->where('nav_item_id', $navItemId)
->where('role_id', $targetRoleId)
->first();
if (!$exists) {
$this->maps->insert([
'role_id' => $targetRoleId,
'nav_item_id' => $navItemId,
]);
}
}
} else {
return $this->response
->setStatusCode(422)
->setJSON(['ok' => false, 'message' => 'Unsupported role access action.', 'csrf' => $this->csrfPayload()]);
}
$this->service->clearCache();
return $this->response->setJSON([
'ok' => true,
'message' => 'Role access updated.',
'csrf' => $this->csrfPayload(),
'payload' => $this->buildNavPayload(),
]);
}
private function roleExists(int $roleId): bool
{
if ($roleId <= 0) {
return false;
}
$db = \Config\Database::connect();
return (bool) $db->table('roles')
->select('id')
->where('id', $roleId)
->get(1)
->getFirstRow();
}
private function normalizeStructureUpdates(array $structure): array
{
$existingRows = $this->items->select('id, menu_parent_id')->findAll();
$existingIds = array_map('intval', array_column($existingRows, 'id'));
$existingIdLookup = array_fill_keys($existingIds, true);
$updates = [];
foreach ($structure as $row) {
if (!is_array($row)) {
continue;
}
$id = (int) ($row['id'] ?? 0);
if ($id <= 0 || !isset($existingIdLookup[$id])) {
continue;
}
$parentId = $row['parent_id'] ?? null;
$parentId = ($parentId === '' || $parentId === null) ? null : (int) $parentId;
if ($parentId !== null && (!isset($existingIdLookup[$parentId]) || $parentId === $id)) {
$parentId = null;
}
$updates[] = [
'id' => $id,
'menu_parent_id' => $parentId,
'sort_order' => max(0, (int) ($row['sort_order'] ?? 0)),
];
}
$updatesById = [];
foreach ($updates as $row) {
$updatesById[$row['id']] = $row;
}
foreach ($updatesById as $id => &$row) {
if ($row['menu_parent_id'] !== null && $this->wouldCreateCycle($id, $row['menu_parent_id'], $updatesById, $existingRows)) {
$row['menu_parent_id'] = null;
}
}
unset($row);
return array_values($updatesById);
}
private function wouldCreateCycle(int $id, int $parentId, array $updatesById, array $existingRows): bool
{
$parentById = [];
foreach ($existingRows as $row) {
$parentById[(int) ($row['id'] ?? 0)] = isset($row['menu_parent_id']) && (int) $row['menu_parent_id'] !== 0
? (int) $row['menu_parent_id']
: null;
}
foreach ($updatesById as $row) {
$parentById[$row['id']] = $row['menu_parent_id'];
}
$seen = [];
$current = $parentId;
while ($current !== null) {
if ($current === $id) {
return true;
}
if (isset($seen[$current])) {
return true;
}
$seen[$current] = true;
$current = $parentById[$current] ?? null;
}
return false;
}
protected function distinctRoles(): array
@@ -207,7 +391,7 @@ public function save()
}
unset($node);
$this->sortTreeAlpha($tree);
$this->sortTreeByOrder($tree);
$flatAlpha = $all;
usort($flatAlpha, fn($a, $b) => strnatcasecmp(
@@ -256,9 +440,59 @@ public function save()
'items' => $flattened,
'roles' => $roles,
'parentOptions' => $parentOptions,
'routeOptions' => $this->routeOptions(),
];
}
private function routeOptions(): array
{
$routes = service('routes');
$getRoutes = $routes->getRoutes('GET', false);
if (empty($getRoutes)) {
$routes = $routes->loadRoutes();
$getRoutes = $routes->getRoutes('GET', false);
}
$options = [];
foreach (array_keys($getRoutes) as $route) {
$route = trim((string) $route, '/');
if ($route === '' || $this->shouldHideRouteOption($route)) {
continue;
}
$options[] = [
'value' => $route,
'label' => $this->routeLabel($route),
'needs_params' => str_contains($route, '(') || str_contains($route, '[') || str_contains($route, '{'),
];
}
usort($options, static function ($a, $b) {
return strnatcasecmp($a['label'] ?? '', $b['label'] ?? '');
});
return $options;
}
private function shouldHideRouteOption(string $route): bool
{
if (str_starts_with($route, 'api/') || str_starts_with($route, 'docs/') || str_starts_with($route, 'debugbar/')) {
return true;
}
return (bool) preg_match('#(^|/)(csrf-token|file|attachment|download|delete)(/|$)#i', $route);
}
private function routeLabel(string $route): string
{
$label = preg_replace('#\(\?:[^)]+\)|\(\[\^/\]\+\)|\(\[0-9\]\+\)|\(:[a-z_]+\)|\{[^}]+\}#i', '{value}', $route) ?? $route;
$label = str_replace(['_', '-'], ' ', $label);
$label = preg_replace('#/+#', ' / ', $label) ?? $label;
$label = preg_replace('/\s+/', ' ', $label) ?? $label;
return ucwords(trim($label));
}
private function flattenTreeForResponse(array $nodes, array $roleAssignments, ?string $parentLabel = null, int $depth = 0, array &$rows = []): array
{
foreach ($nodes as $node) {
@@ -300,4 +534,28 @@ public function save()
return $rows;
}
private function sortTreeByOrder(array &$nodes): void
{
usort($nodes, function ($a, $b) {
$order = ((int) ($a['sort_order'] ?? 0)) <=> ((int) ($b['sort_order'] ?? 0));
if ($order !== 0) {
return $order;
}
$label = strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? ''));
if ($label !== 0) {
return $label;
}
return ((int) ($a['id'] ?? 0)) <=> ((int) ($b['id'] ?? 0));
});
foreach ($nodes as &$node) {
if (!empty($node['children'])) {
$this->sortTreeByOrder($node['children']);
}
}
unset($node);
}
}