re-design the menu builder page
This commit is contained in:
@@ -941,11 +941,12 @@ $routes->post('/parent/edit_emergency_contact/(:num)', 'View\ParentController::e
|
|||||||
|
|
||||||
|
|
||||||
/*management navigation bar*/
|
/*management navigation bar*/
|
||||||
$routes->get('nav-builder', 'View\NavBuilderController::index', ['filter' => 'auth']);
|
$routes->get('nav-builder', 'View\NavBuilderController::index', ['filter' => 'auth:administrator']);
|
||||||
$routes->get('api/nav-builder', 'View\NavBuilderController::data', ['filter' => 'auth']);
|
$routes->get('api/nav-builder', 'View\NavBuilderController::data', ['filter' => 'auth:administrator']);
|
||||||
$routes->post('nav-builder/save', 'View\NavBuilderController::save', ['filter' => 'auth']);
|
$routes->post('nav-builder/save', 'View\NavBuilderController::save', ['filter' => 'auth:administrator']);
|
||||||
$routes->get('nav-builder/delete/(:num)', 'View\NavBuilderController::delete/$1', ['filter' => 'auth']);
|
$routes->get('nav-builder/delete/(:num)', 'View\NavBuilderController::delete/$1', ['filter' => 'auth:administrator']);
|
||||||
$routes->post('nav-builder/reorder', 'View\NavBuilderController::reorder', ['filter' => 'auth']);
|
$routes->post('nav-builder/reorder', 'View\NavBuilderController::reorder', ['filter' => 'auth:administrator']);
|
||||||
|
$routes->post('nav-builder/role-access', 'View\NavBuilderController::roleAccess', ['filter' => 'auth:administrator']);
|
||||||
|
|
||||||
|
|
||||||
// Teacher book distribution is the only inventory path teachers may access directly.
|
// Teacher book distribution is the only inventory path teachers may access directly.
|
||||||
|
|||||||
@@ -22,33 +22,14 @@ class NavBuilderController extends BaseController
|
|||||||
|
|
||||||
protected function ensureAdmin(): void
|
protected function ensureAdmin(): void
|
||||||
{
|
{
|
||||||
$sessionRole = session()->get('role'); // could be a string or array in your app
|
$session = session();
|
||||||
$roleNames = is_array($sessionRole) ? $sessionRole : [$sessionRole];
|
$roles = array_filter(array_merge(
|
||||||
$roleNames = array_values(array_filter(array_map('strval', $roleNames)));
|
(array) $session->get('roles'),
|
||||||
|
(array) $session->get('role')
|
||||||
|
));
|
||||||
|
|
||||||
if (empty($roleNames)) {
|
$normalizedRoles = array_map(static fn ($role) => strtolower(trim((string) $role)), $roles);
|
||||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
if (!in_array('administrator', $normalizedRoles, true)) {
|
||||||
}
|
|
||||||
|
|
||||||
$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
|
|
||||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,6 +87,15 @@ public function save()
|
|||||||
if ($menuParentId !== null) {
|
if ($menuParentId !== null) {
|
||||||
$parent = $this->items->select('id')->where('id', $menuParentId)->first();
|
$parent = $this->items->select('id')->where('id', $menuParentId)->first();
|
||||||
if (!$parent) {
|
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();
|
return redirect()->back()->with('error', 'Selected parent does not exist.')->withInput();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,9 +129,33 @@ public function save()
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->service->clearCache();
|
$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.');
|
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)
|
public function delete($id)
|
||||||
{
|
{
|
||||||
@@ -158,12 +172,182 @@ public function save()
|
|||||||
{
|
{
|
||||||
$this->ensureAdmin();
|
$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') ?? [];
|
$orders = $this->request->getPost('orders') ?? [];
|
||||||
foreach ($orders as $id => $order) {
|
foreach ($orders as $id => $order) {
|
||||||
$this->items->update((int) $id, ['sort_order' => (int) $order]);
|
$this->items->update((int) $id, ['sort_order' => (int) $order]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->service->clearCache();
|
$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
|
protected function distinctRoles(): array
|
||||||
@@ -207,7 +391,7 @@ public function save()
|
|||||||
}
|
}
|
||||||
unset($node);
|
unset($node);
|
||||||
|
|
||||||
$this->sortTreeAlpha($tree);
|
$this->sortTreeByOrder($tree);
|
||||||
|
|
||||||
$flatAlpha = $all;
|
$flatAlpha = $all;
|
||||||
usort($flatAlpha, fn($a, $b) => strnatcasecmp(
|
usort($flatAlpha, fn($a, $b) => strnatcasecmp(
|
||||||
@@ -256,9 +440,59 @@ public function save()
|
|||||||
'items' => $flattened,
|
'items' => $flattened,
|
||||||
'roles' => $roles,
|
'roles' => $roles,
|
||||||
'parentOptions' => $parentOptions,
|
'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
|
private function flattenTreeForResponse(array $nodes, array $roleAssignments, ?string $parentLabel = null, int $depth = 0, array &$rows = []): array
|
||||||
{
|
{
|
||||||
foreach ($nodes as $node) {
|
foreach ($nodes as $node) {
|
||||||
@@ -300,4 +534,28 @@ public function save()
|
|||||||
|
|
||||||
return $rows;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,9 +29,7 @@ class NavbarService
|
|||||||
$allowedIds = $this->mapModel->getNavItemIdsForRoles($roles);
|
$allowedIds = $this->mapModel->getNavItemIdsForRoles($roles);
|
||||||
if (empty($allowedIds)) return [];
|
if (empty($allowedIds)) return [];
|
||||||
|
|
||||||
// Load all enabled items, then filter.
|
// Load all enabled items, then filter. The builder controls this order.
|
||||||
// The displayed sidebar is alphabetized after the tree is built so
|
|
||||||
// new menu items fall into place without relying on manual sort_order.
|
|
||||||
$rows = $this->navModel->where('is_enabled', 1)
|
$rows = $this->navModel->where('is_enabled', 1)
|
||||||
->orderBy('sort_order', 'ASC')
|
->orderBy('sort_order', 'ASC')
|
||||||
->orderBy('label', 'ASC')
|
->orderBy('label', 'ASC')
|
||||||
@@ -59,22 +57,34 @@ class NavbarService
|
|||||||
}
|
}
|
||||||
unset($node);
|
unset($node);
|
||||||
|
|
||||||
$this->sortTreeAlphabetically($tree);
|
$this->sortTreeByOrder($tree);
|
||||||
|
|
||||||
$this->cache->save($cacheKey, $tree, 300); // 5 minutes
|
$this->cache->save($cacheKey, $tree, 300); // 5 minutes
|
||||||
return $tree;
|
return $tree;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function sortTreeAlphabetically(array &$nodes): void
|
private function sortTreeByOrder(array &$nodes): void
|
||||||
{
|
{
|
||||||
usort($nodes, fn ($a, $b) => strnatcasecmp(
|
usort($nodes, function ($a, $b) {
|
||||||
$this->labelSortKey((string) ($a['label'] ?? '')),
|
$order = ((int) ($a['sort_order'] ?? 0)) <=> ((int) ($b['sort_order'] ?? 0));
|
||||||
$this->labelSortKey((string) ($b['label'] ?? ''))
|
if ($order !== 0) {
|
||||||
));
|
return $order;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = strnatcasecmp(
|
||||||
|
$this->labelSortKey((string) ($a['label'] ?? '')),
|
||||||
|
$this->labelSortKey((string) ($b['label'] ?? ''))
|
||||||
|
);
|
||||||
|
if ($label !== 0) {
|
||||||
|
return $label;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ((int) ($a['id'] ?? 0)) <=> ((int) ($b['id'] ?? 0));
|
||||||
|
});
|
||||||
|
|
||||||
foreach ($nodes as &$node) {
|
foreach ($nodes as &$node) {
|
||||||
if (!empty($node['children']) && is_array($node['children'])) {
|
if (!empty($node['children']) && is_array($node['children'])) {
|
||||||
$this->sortTreeAlphabetically($node['children']);
|
$this->sortTreeByOrder($node['children']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
unset($node);
|
unset($node);
|
||||||
|
|||||||
+1129
-347
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user