feat: add scenarios list page with metrics and quick actions
- Introduced a new template for listing scenarios associated with a project. - Added metrics for total, active, draft, and archived scenarios. - Implemented quick actions for creating new scenarios and reviewing project overview. - Enhanced navigation with breadcrumbs for better user experience. refactor: update Opex and Profitability templates for consistency - Changed titles and button labels for clarity in Opex and Profitability templates. - Updated form IDs and action URLs for better alignment with new naming conventions. - Improved navigation links to include scenario and project overviews. test: add integration tests for Opex calculations - Created new tests for Opex calculation HTML and JSON flows. - Validated successful calculations and ensured correct data persistence. - Implemented tests for currency mismatch and unsupported frequency scenarios. test: enhance project and scenario route tests - Added tests to verify scenario list rendering and calculator shortcuts. - Ensured scenario detail pages link back to the portfolio correctly. - Validated project detail pages show associated scenarios accurately.
This commit is contained in:
230
static/js/navigation_sidebar.js
Normal file
230
static/js/navigation_sidebar.js
Normal file
@@ -0,0 +1,230 @@
|
||||
(function () {
|
||||
const NAV_ENDPOINT = "/navigation/sidebar";
|
||||
const SIDEBAR_SELECTOR = ".sidebar-nav";
|
||||
const DATA_SOURCE_ATTR = "navigationSource";
|
||||
const ROLE_ATTR = "navigationRoles";
|
||||
|
||||
function onReady(callback) {
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", callback, { once: true });
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
function isActivePath(pathname, matchPrefix) {
|
||||
if (!matchPrefix) {
|
||||
return false;
|
||||
}
|
||||
if (matchPrefix === "/") {
|
||||
return pathname === "/";
|
||||
}
|
||||
return pathname.startsWith(matchPrefix);
|
||||
}
|
||||
|
||||
function createAnchor({
|
||||
href,
|
||||
label,
|
||||
matchPrefix,
|
||||
tooltip,
|
||||
isExternal,
|
||||
isActive,
|
||||
className,
|
||||
}) {
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = href;
|
||||
anchor.className = className + (isActive ? " is-active" : "");
|
||||
anchor.dataset.matchPrefix = matchPrefix || href;
|
||||
if (tooltip) {
|
||||
anchor.title = tooltip;
|
||||
}
|
||||
if (isExternal) {
|
||||
anchor.target = "_blank";
|
||||
anchor.rel = "noopener noreferrer";
|
||||
anchor.classList.add("is-external");
|
||||
}
|
||||
anchor.textContent = label;
|
||||
return anchor;
|
||||
}
|
||||
|
||||
function buildLinkBlock(link, pathname) {
|
||||
if (!link || !link.href) {
|
||||
return null;
|
||||
}
|
||||
const matchPrefix = link.match_prefix || link.matchPrefix || link.href;
|
||||
const isActive = isActivePath(pathname, matchPrefix);
|
||||
|
||||
const block = document.createElement("div");
|
||||
block.className = "sidebar-link-block";
|
||||
if (typeof link.id === "number") {
|
||||
block.dataset.linkId = String(link.id);
|
||||
}
|
||||
|
||||
const anchor = createAnchor({
|
||||
href: link.href,
|
||||
label: link.label,
|
||||
matchPrefix,
|
||||
tooltip: link.tooltip,
|
||||
isExternal: Boolean(link.is_external ?? link.isExternal),
|
||||
isActive,
|
||||
className: "sidebar-link",
|
||||
});
|
||||
block.appendChild(anchor);
|
||||
|
||||
const children = Array.isArray(link.children) ? link.children : [];
|
||||
if (children.length > 0) {
|
||||
const container = document.createElement("div");
|
||||
container.className = "sidebar-sublinks";
|
||||
for (const child of children) {
|
||||
if (!child || !child.href) {
|
||||
continue;
|
||||
}
|
||||
const childMatch =
|
||||
child.match_prefix || child.matchPrefix || child.href;
|
||||
const childActive = isActivePath(pathname, childMatch);
|
||||
const childAnchor = createAnchor({
|
||||
href: child.href,
|
||||
label: child.label,
|
||||
matchPrefix: childMatch,
|
||||
tooltip: child.tooltip,
|
||||
isExternal: Boolean(child.is_external ?? child.isExternal),
|
||||
isActive: childActive,
|
||||
className: "sidebar-sublink",
|
||||
});
|
||||
container.appendChild(childAnchor);
|
||||
}
|
||||
if (container.children.length > 0) {
|
||||
block.appendChild(container);
|
||||
}
|
||||
}
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
function buildGroupSection(group, pathname) {
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
const links = Array.isArray(group.links) ? group.links : [];
|
||||
if (links.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const section = document.createElement("div");
|
||||
section.className = "sidebar-section";
|
||||
if (typeof group.id === "number") {
|
||||
section.dataset.groupId = String(group.id);
|
||||
}
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.className = "sidebar-section-label";
|
||||
label.textContent = group.label;
|
||||
section.appendChild(label);
|
||||
|
||||
const linksContainer = document.createElement("div");
|
||||
linksContainer.className = "sidebar-section-links";
|
||||
|
||||
for (const link of links) {
|
||||
const block = buildLinkBlock(link, pathname);
|
||||
if (block) {
|
||||
linksContainer.appendChild(block);
|
||||
}
|
||||
}
|
||||
|
||||
if (linksContainer.children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
section.appendChild(linksContainer);
|
||||
return section;
|
||||
}
|
||||
|
||||
function buildEmptyState() {
|
||||
const section = document.createElement("div");
|
||||
section.className = "sidebar-section sidebar-empty-state";
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.className = "sidebar-section-label";
|
||||
label.textContent = "Navigation";
|
||||
section.appendChild(label);
|
||||
|
||||
const copyWrapper = document.createElement("div");
|
||||
copyWrapper.className = "sidebar-section-links";
|
||||
|
||||
const copy = document.createElement("p");
|
||||
copy.className = "sidebar-empty-copy";
|
||||
copy.textContent = "Navigation is unavailable.";
|
||||
copyWrapper.appendChild(copy);
|
||||
|
||||
section.appendChild(copyWrapper);
|
||||
return section;
|
||||
}
|
||||
|
||||
function renderSidebar(navContainer, payload) {
|
||||
const pathname = window.location.pathname;
|
||||
const groups = Array.isArray(payload?.groups) ? payload.groups : [];
|
||||
navContainer.replaceChildren();
|
||||
|
||||
const rendered = [];
|
||||
for (const group of groups) {
|
||||
const section = buildGroupSection(group, pathname);
|
||||
if (section) {
|
||||
rendered.push(section);
|
||||
}
|
||||
}
|
||||
|
||||
if (rendered.length === 0) {
|
||||
navContainer.appendChild(buildEmptyState());
|
||||
navContainer.dataset[DATA_SOURCE_ATTR] = "client-empty";
|
||||
delete navContainer.dataset[ROLE_ATTR];
|
||||
return;
|
||||
}
|
||||
|
||||
for (const section of rendered) {
|
||||
navContainer.appendChild(section);
|
||||
}
|
||||
|
||||
navContainer.dataset[DATA_SOURCE_ATTR] = "client";
|
||||
const roles = Array.isArray(payload?.roles) ? payload.roles : [];
|
||||
if (roles.length > 0) {
|
||||
navContainer.dataset[ROLE_ATTR] = roles.join(",");
|
||||
} else {
|
||||
delete navContainer.dataset[ROLE_ATTR];
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateSidebar(navContainer) {
|
||||
try {
|
||||
const response = await fetch(NAV_ENDPOINT, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status !== 401 && response.status !== 403) {
|
||||
console.warn(
|
||||
"Navigation sidebar hydration failed with status",
|
||||
response.status
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
renderSidebar(navContainer, payload);
|
||||
} catch (error) {
|
||||
console.warn("Navigation sidebar hydration failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
onReady(() => {
|
||||
const navContainer = document.querySelector(SIDEBAR_SELECTOR);
|
||||
if (!navContainer) {
|
||||
return;
|
||||
}
|
||||
hydrateSidebar(navContainer);
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user