re-design the menu builder page
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user