Files
root ede7fd947a
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Successful in 1m24s
re-design the menu builder page
2026-09-06 21:29:38 -04:00

1235 lines
40 KiB
PHP

<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<style>
.nav-builder-page {
max-width: 1380px;
}
.nav-builder-shell {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(340px, .85fr);
gap: 1rem;
}
.nav-builder-panel {
background: #fff;
border: 1px solid rgba(15, 23, 42, .08);
border-radius: .5rem;
box-shadow: 0 8px 20px rgba(15, 23, 42, .04);
}
.nav-builder-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: .75rem;
padding: 1rem 1.125rem;
border-bottom: 1px solid rgba(15, 23, 42, .08);
}
.nav-builder-panel-body {
padding: 1rem;
}
.nav-tree {
min-height: 360px;
}
.nav-tree-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 260px;
border: 1px dashed rgba(100, 116, 139, .45);
border-radius: .5rem;
color: #64748b;
background: #f8fafc;
}
.nav-drop-zone {
height: .55rem;
border-radius: 999px;
margin: .125rem 0;
transition: background-color .15s ease, height .15s ease;
}
.nav-drop-zone.is-over {
height: 1.2rem;
background: rgba(13, 110, 253, .22);
}
.nav-item-row {
display: grid;
grid-template-columns: 2.25rem minmax(0, 1fr) auto;
align-items: center;
gap: .65rem;
min-height: 3.25rem;
padding: .55rem .7rem;
border: 1px solid rgba(15, 23, 42, .1);
border-radius: .45rem;
background: #fff;
cursor: pointer;
}
.nav-item-row:hover {
border-color: rgba(13, 110, 253, .32);
background: #f8fbff;
}
.nav-item-row.is-selected {
border-color: rgba(13, 110, 253, .75);
box-shadow: 0 0 0 .18rem rgba(13, 110, 253, .12);
}
.nav-item-row.is-dragging {
opacity: .52;
}
.nav-item-handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border: 0;
border-radius: .4rem;
color: #64748b;
background: #f1f5f9;
cursor: grab;
}
.nav-item-main {
min-width: 0;
}
.nav-item-title {
display: flex;
align-items: center;
gap: .45rem;
min-width: 0;
font-weight: 600;
color: #0f172a;
}
.nav-item-title span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-item-meta {
display: flex;
align-items: center;
gap: .45rem;
margin-top: .15rem;
color: #64748b;
font-size: .825rem;
min-width: 0;
}
.nav-item-meta .nav-url {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-item-actions {
display: flex;
align-items: center;
gap: .35rem;
}
.nav-item-toggle {
width: 2rem;
height: 2rem;
padding: 0;
}
.nav-child-zone {
margin-top: .35rem;
margin-left: 2.9rem;
padding: .45rem;
border: 1px dashed transparent;
border-radius: .45rem;
}
.nav-child-zone.is-over {
border-color: rgba(13, 110, 253, .45);
background: rgba(13, 110, 253, .07);
}
.nav-children {
margin-left: 1.25rem;
padding-left: .85rem;
border-left: 2px solid rgba(148, 163, 184, .35);
}
.nav-role-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: .5rem .75rem;
}
.nav-role-grid .form-check {
min-width: 0;
}
.nav-builder-status {
min-height: 1.5rem;
color: #64748b;
font-size: .9rem;
}
.role-access-table td {
vertical-align: top;
}
.role-access-panel .nav-builder-panel-header,
.role-access-panel .role-access-table thead,
.role-access-panel .role-access-table th {
position: static !important;
top: auto !important;
z-index: auto !important;
}
.role-access-pages {
display: flex;
flex-wrap: wrap;
gap: .35rem;
}
.role-access-page {
display: inline-flex;
align-items: center;
gap: .35rem;
max-width: 280px;
padding: .25rem .45rem;
border: 1px solid rgba(15, 23, 42, .08);
border-radius: .35rem;
background: #f8fafc;
color: #334155;
font-size: .82rem;
cursor: grab;
}
.role-access-page.is-updating {
opacity: .55;
pointer-events: none;
}
.role-access-page span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.role-access-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.15rem;
height: 1.15rem;
flex: 0 0 auto;
border: 0;
border-radius: 999px;
background: transparent;
color: #64748b;
padding: 0;
}
.role-access-remove:hover {
background: #e2e8f0;
color: #b42318;
}
.role-access-drop.is-over td {
background: rgba(13, 110, 253, .08);
}
@media (max-width: 992px) {
.nav-builder-shell {
grid-template-columns: 1fr;
}
}
</style>
<div class="container-fluid nav-builder-page py-4"
data-nav-endpoint="<?= site_url('api/nav-builder') ?>"
data-delete-base="<?= site_url('nav-builder/delete') ?>"
data-reorder-endpoint="<?= site_url('nav-builder/reorder') ?>"
data-role-access-endpoint="<?= site_url('nav-builder/role-access') ?>">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 mb-3">
<div>
<h3 class="mb-1">Navbar Builder</h3>
<div class="text-muted">Drag items to reorder them or drop one onto another item to make it a child.</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary" type="button" id="refreshNavButton">
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
</button>
<button class="btn btn-primary" type="button" id="saveOrderButton" disabled>
<i class="bi bi-save me-1"></i>Save order
</button>
</div>
</div>
<?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<div class="nav-builder-shell">
<section class="nav-builder-panel">
<div class="nav-builder-panel-header">
<div>
<h5 class="mb-0">Menu Structure</h5>
<div class="nav-builder-status" id="navBuilderStatus">Loading menu...</div>
</div>
<button class="btn btn-sm btn-outline-primary" type="button" id="newItemButton">
<i class="bi bi-plus-lg me-1"></i>New item
</button>
<button class="btn btn-sm btn-outline-secondary" type="button" id="expandAllButton">
<i class="bi bi-arrows-expand me-1"></i>Expand all
</button>
<button class="btn btn-sm btn-outline-secondary" type="button" id="collapseAllButton">
<i class="bi bi-arrows-collapse me-1"></i>Collapse all
</button>
</div>
<div class="nav-builder-panel-body">
<div class="nav-tree" id="navTree" aria-live="polite"></div>
</div>
</section>
<aside class="nav-builder-panel">
<div class="nav-builder-panel-header">
<div>
<h5 class="mb-0" id="editorTitle">New Item</h5>
<div class="text-muted small">Edit the selected menu item.</div>
</div>
</div>
<div class="nav-builder-panel-body">
<form method="post" action="<?= site_url('nav-builder/save') ?>" id="navBuilderForm">
<?= csrf_field() ?>
<input type="hidden" name="id" id="item_id">
<input type="hidden" name="sort_order" id="sort_order" value="0">
<div class="mb-3">
<label class="form-label" for="label">Label</label>
<input class="form-control" name="label" id="label" required>
</div>
<div class="mb-3">
<label class="form-label" for="url">URL</label>
<select class="form-select" name="url" id="url">
<option value="">No link</option>
</select>
</div>
<div class="mb-3">
<label class="form-label" for="menu_parent_id">Parent</label>
<select name="menu_parent_id" id="menu_parent_id" class="form-select">
<option value="">Top level</option>
</select>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="target">Target</label>
<select name="target" id="target" class="form-select">
<option value="">Default</option>
<option value="_self">Same tab</option>
<option value="_blank">New tab</option>
</select>
</div>
<div class="col-md-6 mb-3 d-flex align-items-end">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="is_enabled" id="is_enabled" checked>
<label class="form-check-label" for="is_enabled">Enabled</label>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Visible to Roles</label>
<div class="nav-role-grid" id="roles"></div>
</div>
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-primary" type="submit">
<i class="bi bi-check-lg me-1"></i>Save item
</button>
<button class="btn btn-outline-secondary" type="button" id="resetFormButton">Clear</button>
<a class="btn btn-outline-danger d-none" href="#" id="deleteItemButton">
<i class="bi bi-trash me-1"></i>Delete
</a>
</div>
</form>
</div>
</aside>
</div>
<section class="nav-builder-panel role-access-panel mt-3">
<div class="nav-builder-panel-header">
<div>
<h5 class="mb-0">Role Access</h5>
<div class="text-muted small">Enabled menu pages assigned to each role.</div>
</div>
<input class="form-control form-control-sm" id="roleAccessSearch" type="search" placeholder="Filter roles or pages" style="max-width: 260px;">
</div>
<div class="nav-builder-panel-body">
<div class="table-responsive">
<table class="table table-sm table-hover align-middle role-access-table no-mgmt-sticky mb-0" data-no-mgmt-sticky>
<thead>
<tr>
<th style="width: 220px;">Role</th>
<th style="width: 90px;">Pages</th>
<th>Access</th>
</tr>
</thead>
<tbody id="roleAccessBody">
<tr>
<td colspan="3" class="text-muted text-center">Loading access...</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
document.addEventListener('DOMContentLoaded', () => {
const container = document.querySelector('[data-nav-endpoint]');
if (!container) return;
const navEndpoint = container.dataset.navEndpoint;
const reorderEndpoint = container.dataset.reorderEndpoint;
const roleAccessEndpoint = container.dataset.roleAccessEndpoint;
const deleteBase = (container.dataset.deleteBase || '').replace(/\/+$/, '');
const treeEl = document.getElementById('navTree');
const statusEl = document.getElementById('navBuilderStatus');
const form = document.getElementById('navBuilderForm');
const saveOrderButton = document.getElementById('saveOrderButton');
const refreshButton = document.getElementById('refreshNavButton');
const newItemButton = document.getElementById('newItemButton');
const expandAllButton = document.getElementById('expandAllButton');
const collapseAllButton = document.getElementById('collapseAllButton');
const resetButton = document.getElementById('resetFormButton');
const deleteButton = document.getElementById('deleteItemButton');
const parentSelect = document.getElementById('menu_parent_id');
const urlSelect = document.getElementById('url');
const rolesEl = document.getElementById('roles');
const editorTitle = document.getElementById('editorTitle');
const roleAccessBody = document.getElementById('roleAccessBody');
const roleAccessSearch = document.getElementById('roleAccessSearch');
let items = [];
let roles = [];
let routeOptions = [];
let selectedId = null;
let draggedId = null;
let isDirty = false;
let collapsedIds = new Set();
let accessFilter = '';
let draggedAccess = null;
const setStatus = (message) => {
statusEl.textContent = message;
};
const setDirty = (dirty) => {
isDirty = dirty;
saveOrderButton.disabled = !dirty;
saveOrderButton.classList.toggle('btn-warning', dirty);
saveOrderButton.classList.toggle('btn-primary', !dirty);
if (dirty) {
setStatus('Unsaved order changes');
}
};
const updateCsrf = (csrf) => {
if (!csrf || !csrf.name || !csrf.hash) return;
const tokenInput = form.querySelector(`input[type="hidden"][name="${csrf.name}"]`);
if (tokenInput) {
tokenInput.value = csrf.hash;
}
};
const getItem = (id) => items.find((item) => Number(item.id) === Number(id));
const getChildren = (parentId) => items
.filter((item) => {
const currentParent = item.parent_id === null || item.parent_id === undefined ? null : Number(item.parent_id);
return currentParent === parentId;
})
.sort((a, b) => {
const orderDiff = Number(a.order || 0) - Number(b.order || 0);
if (orderDiff !== 0) return orderDiff;
return String(a.label || '').localeCompare(String(b.label || ''));
});
const getParentIdsWithChildren = () => items
.filter((item) => getChildren(Number(item.id)).length > 0)
.map((item) => Number(item.id));
const collapseAll = () => {
collapsedIds = new Set(getParentIdsWithChildren());
renderTree();
};
const expandAll = () => {
collapsedIds.clear();
renderTree();
};
const toggleCollapsed = (id) => {
const numericId = Number(id);
if (collapsedIds.has(numericId)) {
collapsedIds.delete(numericId);
} else {
collapsedIds.add(numericId);
}
renderTree();
};
const flattenVisibleTree = (parentId = null, depth = 0, rows = []) => {
getChildren(parentId).forEach((item) => {
rows.push({ ...item, depth });
flattenVisibleTree(Number(item.id), depth + 1, rows);
});
return rows;
};
const isDescendant = (candidateId, possibleParentId) => {
let current = getItem(possibleParentId);
while (current) {
if (Number(current.id) === Number(candidateId)) {
return true;
}
current = current.parent_id ? getItem(current.parent_id) : null;
}
return false;
};
const normalizeSiblingOrders = () => {
const parentIds = new Set([null]);
items.forEach((item) => {
parentIds.add(item.parent_id === null || item.parent_id === undefined ? null : Number(item.parent_id));
});
parentIds.forEach((parentId) => {
getChildren(parentId).forEach((item, index) => {
item.order = (index + 1) * 10;
});
});
};
const nextOrderForParent = (parentId) => {
const siblings = getChildren(parentId);
if (!siblings.length) {
return 10;
}
return Math.max(...siblings.map((item) => Number(item.order || 0))) + 10;
};
const moveItem = (movingId, targetParentId, beforeId = null) => {
if (!movingId) return;
const moving = getItem(movingId);
if (!moving) return;
const normalizedParentId = targetParentId === null || targetParentId === undefined ? null : Number(targetParentId);
if (normalizedParentId !== null && (Number(movingId) === normalizedParentId || isDescendant(movingId, normalizedParentId))) {
setStatus('Cannot move an item inside itself.');
return;
}
moving.parent_id = normalizedParentId;
if (normalizedParentId !== null) {
collapsedIds.delete(normalizedParentId);
}
normalizeSiblingOrders();
const siblings = getChildren(normalizedParentId).filter((item) => Number(item.id) !== Number(movingId));
const foundIndex = beforeId === null
? siblings.length
: siblings.findIndex((item) => Number(item.id) === Number(beforeId));
const insertIndex = foundIndex < 0 ? siblings.length : foundIndex;
siblings.splice(insertIndex, 0, moving);
siblings.forEach((item, index) => {
item.order = (index + 1) * 10;
});
selectedId = Number(movingId);
document.getElementById('sort_order').value = String(moving.order || 0);
renderTree();
renderParentOptions();
renderRoleAccessTable();
setDirty(true);
};
const clearDropState = () => {
document.querySelectorAll('.nav-drop-zone.is-over, .nav-child-zone.is-over')
.forEach((el) => el.classList.remove('is-over'));
};
const attachDropHandlers = (element, onDrop) => {
element.addEventListener('dragover', (event) => {
event.preventDefault();
element.classList.add('is-over');
});
element.addEventListener('dragleave', () => {
element.classList.remove('is-over');
});
element.addEventListener('drop', (event) => {
event.preventDefault();
event.stopPropagation();
clearDropState();
onDrop();
});
};
const renderDropZone = (parent, targetParentId, beforeId = null) => {
const zone = document.createElement('div');
zone.className = 'nav-drop-zone';
attachDropHandlers(zone, () => moveItem(draggedId, targetParentId, beforeId));
parent.appendChild(zone);
};
const renderBranch = (parent, parentId = null) => {
const children = getChildren(parentId);
children.forEach((item) => {
renderDropZone(parent, parentId, Number(item.id));
const wrapper = document.createElement('div');
wrapper.className = 'nav-node';
const row = document.createElement('div');
row.className = 'nav-item-row';
row.draggable = true;
row.dataset.itemId = item.id;
if (Number(item.id) === Number(selectedId)) {
row.classList.add('is-selected');
}
row.addEventListener('click', () => selectItem(item.id));
row.addEventListener('dragstart', (event) => {
draggedId = Number(item.id);
row.classList.add('is-dragging');
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(item.id));
});
row.addEventListener('dragend', () => {
draggedId = null;
row.classList.remove('is-dragging');
clearDropState();
});
const handle = document.createElement('span');
handle.className = 'nav-item-handle';
handle.title = 'Drag';
handle.innerHTML = '<i class="bi bi-grip-vertical"></i>';
row.appendChild(handle);
const main = document.createElement('div');
main.className = 'nav-item-main';
const title = document.createElement('div');
title.className = 'nav-item-title';
title.innerHTML = item.enabled
? '<i class="bi bi-circle-fill text-success small"></i>'
: '<i class="bi bi-circle text-muted small"></i>';
const label = document.createElement('span');
label.textContent = item.label || '(Untitled)';
title.appendChild(label);
main.appendChild(title);
const meta = document.createElement('div');
meta.className = 'nav-item-meta';
const childCount = getChildren(Number(item.id)).length;
meta.innerHTML = `<span>${childCount} child${childCount === 1 ? '' : 'ren'}</span>`;
const url = document.createElement('span');
url.className = 'nav-url';
url.textContent = item.url || 'No URL';
meta.appendChild(url);
main.appendChild(meta);
row.appendChild(main);
const actions = document.createElement('div');
actions.className = 'nav-item-actions';
if (childCount > 0) {
const toggleButton = document.createElement('button');
const isCollapsed = collapsedIds.has(Number(item.id));
toggleButton.type = 'button';
toggleButton.className = 'btn btn-sm btn-outline-secondary nav-item-toggle';
toggleButton.title = isCollapsed ? 'Expand' : 'Collapse';
toggleButton.setAttribute('aria-label', isCollapsed ? 'Expand item' : 'Collapse item');
toggleButton.innerHTML = isCollapsed
? '<i class="bi bi-chevron-right"></i>'
: '<i class="bi bi-chevron-down"></i>';
toggleButton.addEventListener('click', (event) => {
event.stopPropagation();
toggleCollapsed(item.id);
});
actions.appendChild(toggleButton);
}
const childDrop = document.createElement('button');
childDrop.type = 'button';
childDrop.className = 'btn btn-sm btn-outline-secondary';
childDrop.title = 'Select';
childDrop.innerHTML = '<i class="bi bi-pencil"></i>';
childDrop.addEventListener('click', (event) => {
event.stopPropagation();
selectItem(item.id);
});
actions.appendChild(childDrop);
row.appendChild(actions);
wrapper.appendChild(row);
const childZone = document.createElement('div');
childZone.className = 'nav-child-zone text-muted small';
childZone.textContent = childCount > 0 && collapsedIds.has(Number(item.id))
? `Drop here to place under ${item.label || 'this item'} (${childCount} hidden)`
: 'Drop here to place under ' + (item.label || 'this item');
attachDropHandlers(childZone, () => moveItem(draggedId, Number(item.id)));
wrapper.appendChild(childZone);
if (!collapsedIds.has(Number(item.id))) {
const branch = document.createElement('div');
branch.className = 'nav-children';
renderBranch(branch, Number(item.id));
if (branch.children.length > 0) {
wrapper.appendChild(branch);
}
}
parent.appendChild(wrapper);
});
renderDropZone(parent, parentId, null);
};
const renderTree = () => {
treeEl.innerHTML = '';
if (!items.length) {
const empty = document.createElement('div');
empty.className = 'nav-tree-empty';
empty.textContent = 'No menu items yet.';
treeEl.appendChild(empty);
attachDropHandlers(empty, () => moveItem(draggedId, null));
return;
}
renderBranch(treeEl, null);
const count = items.length;
if (!isDirty) {
setStatus(`${count} menu item${count === 1 ? '' : 's'}`);
}
};
const renderParentOptions = () => {
const currentValue = parentSelect.value;
parentSelect.innerHTML = '<option value="">Top level</option>';
flattenVisibleTree().forEach((item) => {
if (selectedId && Number(item.id) === Number(selectedId)) {
return;
}
if (selectedId && isDescendant(selectedId, Number(item.id))) {
return;
}
const option = document.createElement('option');
option.value = String(item.id);
option.textContent = `${'-- '.repeat(item.depth || 0)}${item.label || '(Untitled)'}`;
parentSelect.appendChild(option);
});
if (Array.from(parentSelect.options).some((option) => option.value === currentValue)) {
parentSelect.value = currentValue;
}
};
const normalizeRouteValue = (value) => String(value || '').replace(/^\/+/, '');
const renderRouteOptions = (selectedUrl = '') => {
const selectedValue = normalizeRouteValue(selectedUrl);
urlSelect.innerHTML = '<option value="">No link</option>';
const hasSelectedValue = routeOptions.some((route) => normalizeRouteValue(route.value) === selectedValue);
if (selectedValue && !hasSelectedValue) {
const currentOption = document.createElement('option');
currentOption.value = selectedValue;
currentOption.textContent = `Current custom URL: ${selectedValue}`;
urlSelect.appendChild(currentOption);
}
routeOptions.forEach((route) => {
const option = document.createElement('option');
option.value = normalizeRouteValue(route.value);
option.textContent = route.needs_params
? `${route.label} - ${route.value} (needs value)`
: `${route.label} - ${route.value}`;
urlSelect.appendChild(option);
});
urlSelect.value = selectedValue;
};
const renderRoles = (selectedIds = []) => {
const selectedLookup = new Set(selectedIds.map((id) => String(id)));
rolesEl.innerHTML = '';
roles.forEach((role) => {
const id = `role-${role.id}`;
const wrapper = document.createElement('div');
wrapper.className = 'form-check';
wrapper.innerHTML = `
<input class="form-check-input" type="checkbox" name="roles[]" id="${id}" value="${role.id}">
<label class="form-check-label text-truncate" for="${id}"></label>
`;
const input = wrapper.querySelector('input');
const label = wrapper.querySelector('label');
input.checked = selectedLookup.has(String(role.id));
label.textContent = role.name || ('Role #' + role.id);
rolesEl.appendChild(wrapper);
});
};
const renderRoleAccessTable = () => {
const normalizedFilter = accessFilter.trim().toLowerCase();
roleAccessBody.innerHTML = '';
const rows = roles.map((role) => {
const pages = flattenVisibleTree()
.filter((item) => item.enabled && Array.isArray(item.roles?.ids) && item.roles.ids.map(String).includes(String(role.id)))
.map((item) => ({
id: item.id,
label: item.label || '(Untitled)',
url: item.url || '',
depth: item.depth || 0,
}));
return {
role,
pages,
searchText: [
role.name || '',
...pages.flatMap((page) => [page.label, page.url]),
].join(' ').toLowerCase(),
};
}).filter((row) => !normalizedFilter || row.searchText.includes(normalizedFilter));
if (!rows.length) {
const emptyRow = document.createElement('tr');
emptyRow.innerHTML = '<td colspan="3" class="text-muted text-center">No matching role access found.</td>';
roleAccessBody.appendChild(emptyRow);
return;
}
rows.forEach((row) => {
const tr = document.createElement('tr');
tr.className = 'role-access-drop';
tr.dataset.roleId = row.role.id;
tr.addEventListener('dragover', (event) => {
if (!draggedAccess || Number(draggedAccess.sourceRoleId) === Number(row.role.id)) return;
event.preventDefault();
tr.classList.add('is-over');
});
tr.addEventListener('dragleave', () => {
tr.classList.remove('is-over');
});
tr.addEventListener('drop', (event) => {
event.preventDefault();
tr.classList.remove('is-over');
if (!draggedAccess || Number(draggedAccess.sourceRoleId) === Number(row.role.id)) return;
updateRoleAccess({
action: 'move',
navItemId: draggedAccess.navItemId,
sourceRoleId: draggedAccess.sourceRoleId,
targetRoleId: row.role.id,
});
});
const roleCell = document.createElement('td');
roleCell.className = 'fw-semibold';
roleCell.textContent = row.role.name || ('Role #' + row.role.id);
tr.appendChild(roleCell);
const countCell = document.createElement('td');
countCell.textContent = String(row.pages.length);
tr.appendChild(countCell);
const pagesCell = document.createElement('td');
if (!row.pages.length) {
pagesCell.innerHTML = '<span class="text-muted">No enabled pages assigned</span>';
} else {
const wrap = document.createElement('div');
wrap.className = 'role-access-pages';
row.pages.forEach((page) => {
const badge = document.createElement('span');
badge.className = 'role-access-page';
badge.draggable = true;
badge.dataset.navItemId = page.id;
badge.dataset.roleId = row.role.id;
badge.title = page.url ? `${page.label} - ${page.url}` : page.label;
badge.addEventListener('dragstart', (event) => {
draggedAccess = {
navItemId: page.id,
sourceRoleId: row.role.id,
};
badge.classList.add('is-updating');
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', JSON.stringify(draggedAccess));
});
badge.addEventListener('dragend', () => {
draggedAccess = null;
badge.classList.remove('is-updating');
document.querySelectorAll('.role-access-drop.is-over').forEach((el) => el.classList.remove('is-over'));
});
const text = document.createElement('span');
text.textContent = `${'-- '.repeat(page.depth)}${page.label}${page.url ? ` (${page.url})` : ''}`;
badge.appendChild(text);
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'role-access-remove';
removeButton.title = 'Remove access';
removeButton.setAttribute('aria-label', 'Remove access');
removeButton.innerHTML = '<i class="bi bi-x"></i>';
removeButton.addEventListener('click', (event) => {
event.stopPropagation();
updateRoleAccess({
action: 'remove',
navItemId: page.id,
sourceRoleId: row.role.id,
});
});
badge.appendChild(removeButton);
wrap.appendChild(badge);
});
pagesCell.appendChild(wrap);
}
tr.appendChild(pagesCell);
roleAccessBody.appendChild(tr);
});
};
const applyPayload = (payload) => {
items = Array.isArray(payload?.items) ? payload.items.map(normalizePayloadItem) : [];
roles = Array.isArray(payload?.roles) ? payload.roles : [];
routeOptions = Array.isArray(payload?.routeOptions) ? payload.routeOptions : [];
};
const normalizePayloadItem = (item) => ({
...item,
id: Number(item.id),
parent_id: item.parent_id === null || item.parent_id === undefined ? null : Number(item.parent_id),
order: Number(item.order || 0),
});
const mergeSavedItemFromPayload = (payload, savedId) => {
roles = Array.isArray(payload?.roles) ? payload.roles : roles;
routeOptions = Array.isArray(payload?.routeOptions) ? payload.routeOptions : routeOptions;
const savedItem = Array.isArray(payload?.items)
? payload.items.find((item) => Number(item.id) === Number(savedId))
: null;
if (!savedItem) return;
const normalized = normalizePayloadItem(savedItem);
const existingIndex = items.findIndex((item) => Number(item.id) === Number(savedId));
if (existingIndex >= 0) {
items[existingIndex] = normalized;
} else {
items.push(normalized);
}
};
const renderCurrentState = () => {
renderRouteOptions(selectedId ? (getItem(selectedId)?.url || '') : '');
renderRoles(selectedId && getItem(selectedId) && Array.isArray(getItem(selectedId).roles?.ids)
? getItem(selectedId).roles.ids
: []);
renderParentOptions();
if (selectedId && getItem(selectedId)) {
parentSelect.value = getItem(selectedId).parent_id ? String(getItem(selectedId).parent_id) : '';
}
renderRoleAccessTable();
renderTree();
};
const resetForm = () => {
form.reset();
selectedId = null;
document.getElementById('item_id').value = '';
document.getElementById('sort_order').value = String(nextOrderForParent(null));
renderRouteOptions('');
parentSelect.value = '';
editorTitle.textContent = 'New Item';
deleteButton.classList.add('d-none');
deleteButton.href = '#';
renderRoles([]);
renderParentOptions();
renderRoleAccessTable();
renderTree();
};
const selectItem = (id) => {
const item = getItem(id);
if (!item) return;
selectedId = Number(id);
editorTitle.textContent = 'Edit Item';
document.getElementById('item_id').value = item.id || '';
document.getElementById('label').value = item.label || '';
renderRouteOptions(item.url || '');
document.getElementById('sort_order').value = Number(item.order || 0);
document.getElementById('target').value = item.target || '';
document.getElementById('is_enabled').checked = !!item.enabled;
parentSelect.value = item.parent_id ? String(item.parent_id) : '';
deleteButton.href = `${deleteBase}/${item.id}`;
deleteButton.classList.remove('d-none');
renderRoles(Array.isArray(item.roles?.ids) ? item.roles.ids : []);
renderParentOptions();
parentSelect.value = item.parent_id ? String(item.parent_id) : '';
renderRoleAccessTable();
renderTree();
};
const fetchNavData = () => {
setStatus('Loading menu...');
treeEl.innerHTML = '<div class="nav-tree-empty">Loading menu...</div>';
return fetch(navEndpoint, {
headers: { 'Accept': 'application/json' },
credentials: 'same-origin',
})
.then((response) => {
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.json();
})
.then((payload) => {
applyPayload(payload);
collapsedIds = new Set(getParentIdsWithChildren());
setDirty(false);
renderRouteOptions('');
renderRoles([]);
renderParentOptions();
renderRoleAccessTable();
resetForm();
})
.catch((error) => {
console.error(error);
treeEl.innerHTML = '<div class="nav-tree-empty text-danger">Failed to load menu items.</div>';
setStatus('Load failed');
});
};
const saveOrder = () => {
normalizeSiblingOrders();
const payloadRows = flattenVisibleTree();
const formData = new FormData();
const csrf = form.querySelector('input[type="hidden"][name]:not(#item_id):not(#sort_order)');
if (csrf) {
formData.append(csrf.name, csrf.value);
}
payloadRows.forEach((item, index) => {
formData.append(`structure[${index}][id]`, item.id);
formData.append(`structure[${index}][parent_id]`, item.parent_id === null ? '' : item.parent_id);
formData.append(`structure[${index}][sort_order]`, item.order);
});
saveOrderButton.disabled = true;
setStatus('Saving order...');
fetch(reorderEndpoint, {
method: 'POST',
body: formData,
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
})
.then((response) => {
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response.json();
})
.then((payload) => {
updateCsrf(payload.csrf);
setDirty(false);
setStatus('Order saved');
fetchNavData();
})
.catch((error) => {
console.error(error);
saveOrderButton.disabled = false;
setStatus('Order save failed');
});
};
const saveItem = (event) => {
event.preventDefault();
const submitButton = form.querySelector('button[type="submit"]');
const previousSelection = selectedId;
const hadUnsavedOrder = isDirty;
const formData = new FormData(form);
submitButton.disabled = true;
setStatus('Saving item...');
fetch(form.action, {
method: 'POST',
body: formData,
credentials: 'same-origin',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
})
.then((response) => {
if (!response.ok) {
return response.json()
.catch(() => ({}))
.then((payload) => {
throw new Error(payload.message || `Request failed with status ${response.status}`);
});
}
return response.json();
})
.then((payload) => {
updateCsrf(payload.csrf);
const savedId = Number(payload.id || previousSelection || 0);
if (payload.payload) {
if (hadUnsavedOrder) {
mergeSavedItemFromPayload(payload.payload, savedId);
} else {
applyPayload(payload.payload);
}
}
setDirty(hadUnsavedOrder);
renderRouteOptions('');
renderRoles([]);
renderParentOptions();
renderRoleAccessTable();
renderTree();
if (savedId > 0 && getItem(savedId)) {
selectItem(savedId);
}
setStatus(hadUnsavedOrder ? 'Item saved. Order changes still need saving.' : (payload.message || 'Item saved'));
})
.catch((error) => {
console.error(error);
setStatus(error.message || 'Item save failed');
})
.finally(() => {
submitButton.disabled = false;
});
};
const updateRoleAccess = ({ action, navItemId, sourceRoleId, targetRoleId = null }) => {
const formData = new FormData();
const csrf = form.querySelector('input[type="hidden"][name]:not(#item_id):not(#sort_order)');
if (csrf) {
formData.append(csrf.name, csrf.value);
}
formData.append('action', action);
formData.append('nav_item_id', navItemId);
formData.append('source_role_id', sourceRoleId);
if (targetRoleId !== null) {
formData.append('target_role_id', targetRoleId);
}
setStatus(action === 'remove' ? 'Removing role access...' : 'Moving role access...');
fetch(roleAccessEndpoint, {
method: 'POST',
body: formData,
credentials: 'same-origin',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
})
.then((response) => {
if (!response.ok) {
return response.json()
.catch(() => ({}))
.then((payload) => {
throw new Error(payload.message || `Request failed with status ${response.status}`);
});
}
return response.json();
})
.then((payload) => {
updateCsrf(payload.csrf);
if (payload.payload) {
applyPayload(payload.payload);
}
renderCurrentState();
setStatus(payload.message || 'Role access updated');
})
.catch((error) => {
console.error(error);
setStatus(error.message || 'Role access update failed');
renderRoleAccessTable();
});
};
parentSelect.addEventListener('change', () => {
const nextParent = parentSelect.value ? Number(parentSelect.value) : null;
if (!selectedId) {
document.getElementById('sort_order').value = String(nextOrderForParent(nextParent));
return;
}
const item = getItem(selectedId);
if (!item) return;
moveItem(item.id, nextParent);
});
deleteButton.addEventListener('click', (event) => {
if (!selectedId || !confirm('Delete this menu item?')) {
event.preventDefault();
}
});
form.addEventListener('submit', saveItem);
saveOrderButton.addEventListener('click', saveOrder);
refreshButton.addEventListener('click', fetchNavData);
newItemButton.addEventListener('click', resetForm);
expandAllButton.addEventListener('click', expandAll);
collapseAllButton.addEventListener('click', collapseAll);
resetButton.addEventListener('click', resetForm);
roleAccessSearch.addEventListener('input', () => {
accessFilter = roleAccessSearch.value;
renderRoleAccessTable();
});
fetchNavData();
});
</script>
<?= $this->endSection() ?>