// Entry point: session, routing, badges and keyboard. import * as api from './api.js'; import * as ui from './ui.js'; import * as poll from './poll.js'; import { state, reset, loadTeams } from './state.js'; import * as queue from './queue.js'; import * as incident from './incident.js'; import * as oncall from './oncall.js'; import * as alerts from './alerts.js'; import * as stats from './stats.js'; import * as account from './account.js'; import * as team from './team.js'; import * as admin from './admin.js'; import * as adminuser from './adminuser.js'; import * as adminteam from './adminteam.js'; const $ = (id) => document.getElementById(id); // One route per section; /incidents/{id} is the queue with a detail open, and // /admin/users/{id} is a section of its own rather than a mode of the Admin // tab, because it replaces the page rather than opening beside it. const SECTIONS = { queue: { title: 'Queue', view: queue }, oncall: { title: 'On-call', view: oncall }, alerts: { title: 'Alerts', view: alerts }, stats: { title: 'Stats', view: stats }, team: { title: 'Team', view: team }, admin: { title: 'Admin', view: admin }, adminuser: { title: 'User', view: adminuser, nav: 'admin' }, adminteam: { title: 'Team', view: adminteam, nav: 'admin' }, more: { title: 'Account', view: account }, }; // The mobile hamburger menu's contents — the same sections the desktop // sidebar's .nav-link list carries in index.html, in the same order. const NAV_ITEMS = [ { path: '/', section: 'queue', label: 'Queue', icon: 'queueList' }, { path: '/oncall', section: 'oncall', label: 'On-call', icon: 'calendar' }, { path: '/alerts', section: 'alerts', label: 'Alerts', icon: 'bell' }, { path: '/stats', section: 'stats', label: 'Stats', icon: 'chart' }, { path: '/team', section: 'team', label: 'Team', icon: 'team' }, { path: '/admin', section: 'admin', label: 'Admin', icon: 'shield', adminOnly: true }, { path: '/more', section: 'more', label: 'Account', icon: 'user' }, ]; function parseRoute(pathname) { const m = pathname.match(/^\/incidents\/(\d+)\/?$/); if (m) return { section: 'queue', incident: Number(m[1]) }; const u = pathname.match(/^\/admin\/users\/(\d+)\/?$/); if (u) return { section: 'adminuser', user: Number(u[1]) }; // Before the TABS lookup below, which matches a path exactly and would let // /admin/teams/7 fall through to the queue. const g = pathname.match(/^\/admin\/teams\/(\d+)\/?$/); if (g) return { section: 'adminteam', team: Number(g[1]) }; const name = pathname.replace(/^\/|\/$/g, ''); // The Admin and Team tabs' sub-sections are routes of their own. Each view // owns the table of its own, since each also builds the strip that links to // them; /team is in team.TABS as the overview, so it is matched here too. const t = admin.TABS.find((x) => x.path === `/${name}`); if (t) return { section: 'admin', tab: t.tab }; const tt = team.TABS.find((x) => x.path === `/${name}`); if (tt) return { section: 'team', tab: tt.tab }; if (name === 'oncall' || name === 'alerts' || name === 'stats' || name === 'more') return { section: name }; return { section: 'queue', incident: null }; } // What the top bar and the document title call this route. The sub-sections of // Admin and Team are pages in their own right, so they say which one rather // than the tab's name four or six times; either overview keeps the tab's own // name. A tab may carry a `title` where its strip label is too short to name a // page on its own. function title(r) { const tabs = r.section === 'admin' ? admin.TABS : r.section === 'team' ? team.TABS : null; const t = tabs && r.tab ? tabs.find((x) => x.tab === r.tab) : null; return t ? (t.title || t.label) : SECTIONS[r.section].title; } let route = parseRoute(location.pathname); // How many in-app navigations deep we are, so Back can use the browser's // history when there is somewhere to go back to, and the queue otherwise. let depth = 0; let listScroll = 0; export function navigate(path, { replace = false } = {}) { if (path === location.pathname + location.search) return; if (replace) { history.replaceState({ depth }, '', path); } else { depth += 1; history.pushState({ depth }, '', path); } render(); } export function back() { if (depth > 0) history.back(); else navigate('/', { replace: true }); } window.addEventListener('popstate', (e) => { depth = (e.state && e.state.depth) || 0; render(); }); function render() { const prev = route; route = parseRoute(location.pathname); const app = $('app'); for (const name of Object.keys(SECTIONS)) { const el = $(`view-${name}`); el.hidden = name !== route.section; if (name === route.section) $('topbar-title').textContent = title(route); } // A section may light up somebody else's tab: /admin/users/{id} is still the // Admin tab as far as the nav is concerned, since there is no tab of its own. const current = SECTIONS[route.section].nav || route.section; for (const link of document.querySelectorAll('.nav-link')) { if (link.dataset.section === current) link.setAttribute('aria-current', 'page'); else link.removeAttribute('aria-current'); } const detailOpen = route.section === 'queue' && route.incident != null; const wasOpen = prev.section === 'queue' && prev.incident != null; if (detailOpen && !wasOpen) listScroll = window.scrollY; app.classList.toggle('detail-open', detailOpen); $('view-queue').classList.toggle('has-detail', detailOpen); if (route.section === 'queue') { queue.show(route.incident); incident.show(route.incident); } else { incident.show(null); SECTIONS[route.section].view.show(route); } if (detailOpen && !wasOpen) window.scrollTo(0, 0); else if (!detailOpen && wasOpen) requestAnimationFrame(() => window.scrollTo(0, listScroll)); // A changed tab counts as a changed page: stepping from a long user list to // the settings should not land you halfway down them. So does a changed // subject — one team to the next is two pages, not one scrolled page. else if (prev.section !== route.section || prev.tab !== route.tab || prev.user !== route.user || prev.team !== route.team) window.scrollTo(0, 0); updateTitle(); } // ---------- nav menu ---------- // The mobile hamburger menu: same shape as the sheet-based action menus in // incident.js (openSheet + a