Add a global, colour-coded team selector to the nav
state.js's currentTeam() was hard-coded to teams[0] and never really meant "the team currently selected" — team.js's settings page and queue.js's filter chips each kept their own separate, unsynchronized notion of "which team" instead, so picking one on one page had no effect on the other. Replaces both with a single state.selectedTeamID, set only through the new setSelectedTeam (persisted in localStorage, unlike the queue's old per-tab sessionStorage filter) and broadcast to listeners via onTeamChange. A new teamselector.js control — a coloured dot plus the team's name, or "All teams" — sits at the top of both the desktop sidebar and the mobile topbar, opening the existing bottom-sheet menu to switch. Shown only once someone is in more than one team, matching every other team-aware control in this app. Colours come from a new teamColorClass() in format.js, hashing a team's id into the six-colour rc1..rc6 palette already used for the rota's per-person chips, so no schema or API change is needed. The queue's team filter chips pick up the same colours.
This commit is contained in:
@@ -261,6 +261,33 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
font-size: 11px; font-weight: 700; line-height: 18px; text-align: center;
|
||||
}
|
||||
|
||||
/* ---------- team selector ---------- */
|
||||
/* The global control for which team the app is scoped to. Hidden (via the
|
||||
`hidden` attribute, set from teamselector.js) for anybody in fewer than two
|
||||
teams, the same rule every other team-aware control in this file follows. */
|
||||
|
||||
.nav-team-selector,
|
||||
.team-selector-mobile {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
border: 1px solid var(--border-strong); border-radius: 999px;
|
||||
background: var(--surface); color: var(--text);
|
||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
padding: 4px 12px; max-width: 100%;
|
||||
}
|
||||
.team-selector-mobile { padding: 4px 10px; font-size: 12px; max-width: 120px; }
|
||||
.team-selector-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* A team's identity colour — not a status, so never the severity palette. Six
|
||||
colours, then they repeat; teamColorClass() in format.js picks one by the
|
||||
team's id, the same rcN convention the rota's per-person chips use. */
|
||||
.team-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--border-strong); }
|
||||
.team-dot.rc1 { background: var(--accent); }
|
||||
.team-dot.rc2 { background: var(--ok); }
|
||||
.team-dot.rc3 { background: var(--snooze); }
|
||||
.team-dot.rc4 { background: var(--warn); }
|
||||
.team-dot.rc5 { background: var(--teal); }
|
||||
.team-dot.rc6 { background: var(--pink); }
|
||||
|
||||
.view { padding-bottom: calc(var(--tabbar-h) + var(--safe-bottom)); }
|
||||
.view-page { padding-left: 16px; padding-right: 16px; }
|
||||
.view-page > * { max-width: 760px; margin-left: auto; margin-right: auto; }
|
||||
@@ -282,6 +309,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
}
|
||||
.chips::-webkit-scrollbar { display: none; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
flex: none;
|
||||
min-height: 34px; padding: 0 12px;
|
||||
border: 1px solid var(--border-strong); border-radius: 999px;
|
||||
@@ -645,6 +673,7 @@ kbd {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 4px 10px 18px; font-size: 18px; font-weight: 750; letter-spacing: -0.01em;
|
||||
}
|
||||
.nav-team-selector { margin: -8px 10px 14px; width: calc(100% - 20px); }
|
||||
.nav-link {
|
||||
flex-direction: row; justify-content: flex-start; gap: 12px;
|
||||
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
|
||||
@@ -784,7 +813,6 @@ kbd {
|
||||
.stacked-form label { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; }
|
||||
.stacked-form label.checkbox { gap: 8px; }
|
||||
.stacked-form input.wide { min-width: min(420px, 100%); }
|
||||
.team-picker { margin-top: 8px; max-width: 100%; }
|
||||
|
||||
/* The rota, a month at a time. A name is too wide to print thirty times and
|
||||
too alike down a column to read, so a day carries an initial in that
|
||||
|
||||
@@ -86,6 +86,9 @@
|
||||
<img src="/icon.svg" alt="" width="28" height="28">
|
||||
<span>terdut</span>
|
||||
</a>
|
||||
<!-- Which team the app is scoped to. Hidden unless the signed-in user is
|
||||
in more than one; teamselector.js fills it in and wires the click. -->
|
||||
<button class="nav-team-selector" id="team-selector" type="button" hidden></button>
|
||||
<a class="nav-link" href="/" data-section="queue" aria-label="Queue">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
|
||||
<span class="nav-label">Queue</span>
|
||||
@@ -126,6 +129,7 @@
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||
<span class="nav-badge menu-btn-badge" data-badge hidden></span>
|
||||
</button>
|
||||
<button class="team-selector-mobile" id="team-selector-mobile" type="button" hidden></button>
|
||||
<h1 class="topbar-title" id="topbar-title">Queue</h1>
|
||||
</div>
|
||||
<span class="open-pill" id="open-pill" hidden></span>
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 teamselector from './teamselector.js';
|
||||
import * as admin from './admin.js';
|
||||
import * as adminuser from './adminuser.js';
|
||||
import * as adminteam from './adminteam.js';
|
||||
@@ -230,6 +231,7 @@ async function boot() {
|
||||
$('login-form').addEventListener('submit', onLogin);
|
||||
$('signup-form').addEventListener('submit', onSignup);
|
||||
$('menu-btn').addEventListener('click', openNavMenu);
|
||||
teamselector.init();
|
||||
ssoErrorCode = takeSSOError();
|
||||
|
||||
// /signup is the one route that works without a session.
|
||||
@@ -245,6 +247,7 @@ async function boot() {
|
||||
await loadAuthConfig();
|
||||
state.me = await api.me();
|
||||
await loadTeams();
|
||||
teamselector.render();
|
||||
// The Admin tab exists only for an administrator. Somebody who types /admin
|
||||
// anyway gets the view's own "ask an administrator" card, not a blank page.
|
||||
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
||||
@@ -328,6 +331,7 @@ async function onSignup(e) {
|
||||
history.replaceState({ depth: 0 }, '', '/');
|
||||
route = parseRoute('/');
|
||||
await loadTeams();
|
||||
teamselector.render();
|
||||
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
||||
showApp();
|
||||
} catch (ex) {
|
||||
|
||||
@@ -98,6 +98,14 @@ export function severityClass(sev) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// A stable identity colour for a team, so the same team always reads the same
|
||||
// colour without the server needing to store one. Teams have no colour field;
|
||||
// this hashes the id into the six-colour rcN palette app.css already has for
|
||||
// the rota's per-person chips (a team is not a status, so never severity).
|
||||
export function teamColorClass(teamID) {
|
||||
return `rc${(((teamID % 6) + 6) % 6) + 1}`;
|
||||
}
|
||||
|
||||
// A one-line summary of the group labels, without the one the title already shows.
|
||||
export function labelSummary(labels, skip = 'alertname') {
|
||||
return Object.entries(labels || {})
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, badge, emptyState, spinner } from './ui.js';
|
||||
import { age, until, isFuture, severityClass, labelSummary } from './format.js';
|
||||
import { state, myID } from './state.js';
|
||||
import { age, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js';
|
||||
import { state, myID, setSelectedTeam, onTeamChange } from './state.js';
|
||||
import * as onboarding from './onboarding.js';
|
||||
import { navigate } from './app.js';
|
||||
|
||||
@@ -27,34 +27,21 @@ const EMPTY = {
|
||||
};
|
||||
|
||||
onboarding.onRerender(() => renderList());
|
||||
// The queue used to keep its own team filter (a per-tab sessionStorage value,
|
||||
// out of step with team.js's own picker); both now defer to the global
|
||||
// selector's shared state, so re-render whenever it changes.
|
||||
onTeamChange(() => {
|
||||
renderChips();
|
||||
refresh({ fresh: true });
|
||||
});
|
||||
|
||||
let filter = loadFilter();
|
||||
let teamFilter = loadTeamFilter(); // '' for every team the viewer is in
|
||||
let items = null; // null while loading
|
||||
let error = null;
|
||||
let selected = null;
|
||||
let cursor = -1; // keyboard position in the list
|
||||
let built = false;
|
||||
|
||||
function loadTeamFilter() {
|
||||
try {
|
||||
return sessionStorage.getItem('terdut.queue.team') || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function setTeamFilter(id) {
|
||||
teamFilter = id;
|
||||
try {
|
||||
sessionStorage.setItem('terdut.queue.team', id);
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
renderChips();
|
||||
refresh({ fresh: true });
|
||||
}
|
||||
|
||||
function loadFilter() {
|
||||
try {
|
||||
const f = sessionStorage.getItem('terdut.queue.filter');
|
||||
@@ -89,8 +76,8 @@ export async function refresh({ fresh = false } = {}) {
|
||||
// The open list is already fetched for the badges; no need to ask twice.
|
||||
// The cached open queue covers every team, so it can only be reused when
|
||||
// no team filter is applied.
|
||||
const query = teamFilter ? { ...f.query, team_id: teamFilter } : f.query;
|
||||
const cached = filter === 'open' && !fresh && !teamFilter;
|
||||
const query = state.selectedTeamID != null ? { ...f.query, team_id: state.selectedTeamID } : f.query;
|
||||
const cached = filter === 'open' && !fresh && state.selectedTeamID == null;
|
||||
const result = cached ? state.open : await api.incidents(query);
|
||||
await onboarding.load();
|
||||
if (requested !== filter) return;
|
||||
@@ -136,19 +123,20 @@ function renderChips() {
|
||||
class: 'chip',
|
||||
type: 'button',
|
||||
role: 'tab',
|
||||
'aria-selected': String(teamFilter === ''),
|
||||
onclick: () => setTeamFilter(''),
|
||||
text: 'All teams',
|
||||
}));
|
||||
'aria-selected': String(state.selectedTeamID == null),
|
||||
onclick: () => setSelectedTeam(null),
|
||||
}, h('span', { class: 'team-dot' }), ' All teams'));
|
||||
for (const team of state.teams) {
|
||||
chips.push(h('button', {
|
||||
class: 'chip',
|
||||
type: 'button',
|
||||
role: 'tab',
|
||||
'aria-selected': String(teamFilter === String(team.id)),
|
||||
onclick: () => setTeamFilter(String(team.id)),
|
||||
text: team.name,
|
||||
}));
|
||||
'aria-selected': String(team.id === state.selectedTeamID),
|
||||
onclick: () => setSelectedTeam(team.id),
|
||||
},
|
||||
h('span', { class: `team-dot ${teamColorClass(team.id)}` }),
|
||||
' ' + team.name,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,53 @@ export const state = {
|
||||
auth: { password_login: true, oidc: { enabled: false, name: '' } },
|
||||
open: [], // the default queue: open, not snoozed
|
||||
teams: [], // the teams the viewer belongs to, each with their role
|
||||
// Which team the whole app is scoped to right now; null means "All teams".
|
||||
// Set only through setSelectedTeam below, never assigned directly, so every
|
||||
// view stays in sync and the choice is remembered across reloads.
|
||||
selectedTeamID: loadSelectedTeam(),
|
||||
};
|
||||
|
||||
// The team whose schedule and settings the views act on. A viewer in one team —
|
||||
// which is everybody until somebody makes a second — never has to choose.
|
||||
const SELECTED_TEAM_KEY = 'terdut.selectedTeam';
|
||||
|
||||
function loadSelectedTeam() {
|
||||
try {
|
||||
const raw = localStorage.getItem(SELECTED_TEAM_KEY);
|
||||
return raw ? Number(raw) : null;
|
||||
} catch {
|
||||
return null; // storage unavailable, or nothing saved yet
|
||||
}
|
||||
}
|
||||
|
||||
// Callbacks to run whenever the selected team changes, so every view that
|
||||
// cares — the queue's filter, the Team settings page, the selector's own
|
||||
// trigger — stays in sync without a general event bus, following the one
|
||||
// precedent for this in the codebase: onboarding.js's onRerender.
|
||||
const teamListeners = [];
|
||||
export function onTeamChange(cb) {
|
||||
teamListeners.push(cb);
|
||||
}
|
||||
|
||||
// setSelectedTeam changes which team the app is scoped to (id, or null for
|
||||
// "All teams"), persists it — a durable preference, unlike the per-tab
|
||||
// sessionStorage filter this replaces — and tells every registered listener.
|
||||
export function setSelectedTeam(id) {
|
||||
state.selectedTeamID = id;
|
||||
try {
|
||||
if (id == null) localStorage.removeItem(SELECTED_TEAM_KEY);
|
||||
else localStorage.setItem(SELECTED_TEAM_KEY, String(id));
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
for (const cb of teamListeners) cb();
|
||||
}
|
||||
|
||||
// The team whose schedule and settings the views act on: the selected team,
|
||||
// falling back to the first one the viewer belongs to — which is everybody's
|
||||
// only team until somebody makes a second, or the stored selection naming a
|
||||
// team the account has since left.
|
||||
export function currentTeam() {
|
||||
return state.teams[0] || null;
|
||||
const teams = state.teams || [];
|
||||
return teams.find((t) => t.id === state.selectedTeamID) || teams[0] || null;
|
||||
}
|
||||
|
||||
export function myID() {
|
||||
@@ -36,6 +77,12 @@ export async function users() {
|
||||
|
||||
export async function loadTeams() {
|
||||
state.teams = await api.teams();
|
||||
// A stored id that no longer names one of the account's teams — left it, or
|
||||
// this is simply a different account signed in on the same browser — is as
|
||||
// good as unset.
|
||||
if (state.selectedTeamID != null && !state.teams.some((t) => t.id === state.selectedTeamID)) {
|
||||
state.selectedTeamID = null;
|
||||
}
|
||||
return state.teams;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip, ssoBadge, SSO_MANAGED } from './ui.js';
|
||||
import { state, currentTeam, users as allUsers, myID } from './state.js';
|
||||
import { state, currentTeam, onTeamChange, users as allUsers, myID } from './state.js';
|
||||
import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js';
|
||||
|
||||
const view = () => document.getElementById('view-team');
|
||||
@@ -41,6 +41,9 @@ export const TABS = [
|
||||
{ tab: 'deadman', path: '/team/deadman', label: 'Switches', title: 'Dead man’s switches' },
|
||||
];
|
||||
|
||||
// Cached from currentTeam() on each refresh(), for the many actions below
|
||||
// (assignSchedule, addTeamMember, ...) that need a plain id rather than a
|
||||
// round trip through state.
|
||||
let teamID = null;
|
||||
// Which sub-section is open. Remembered rather than passed, because the poll
|
||||
// loop calls refresh() with no route.
|
||||
@@ -49,6 +52,13 @@ let data = null; // { team, ... }; which fields are present varies by tab
|
||||
let error = null;
|
||||
let freshKey = null; // an integration key, shown once, until the view is left
|
||||
|
||||
// The global team selector is what changes which team this page shows now;
|
||||
// re-fetch under whichever sub-section is open when it fires.
|
||||
onTeamChange(() => {
|
||||
data = null;
|
||||
refresh();
|
||||
});
|
||||
|
||||
export function show(route) {
|
||||
const next = route?.tab ?? null;
|
||||
// A different sub-section wants different data, so the old answer goes
|
||||
@@ -61,13 +71,8 @@ export function show(route) {
|
||||
refresh();
|
||||
}
|
||||
|
||||
function selectedTeam() {
|
||||
const teams = state.teams || [];
|
||||
return teams.find((t) => t.id === teamID) || currentTeam();
|
||||
}
|
||||
|
||||
export async function refresh() {
|
||||
const team = selectedTeam();
|
||||
const team = currentTeam();
|
||||
if (!team) {
|
||||
data = null;
|
||||
render();
|
||||
@@ -172,24 +177,12 @@ function subnav() {
|
||||
})));
|
||||
}
|
||||
|
||||
// Only shown to somebody in more than one team, like the queue's filter chips.
|
||||
// It is above the sections rather than inside one because it changes the
|
||||
// subject of all six.
|
||||
// Names which team's settings the six sections below belong to. It used to be
|
||||
// a picker of its own for somebody in more than one team; that job now belongs
|
||||
// to the global team selector in the nav, which is what onTeamChange above
|
||||
// reacts to.
|
||||
function teamPicker() {
|
||||
if ((state.teams || []).length < 2) {
|
||||
return h('div', { class: 'card' }, h('h2', { text: data.team.name }));
|
||||
}
|
||||
const select = h('select', { class: 'team-picker' },
|
||||
...state.teams.map((t) => h('option', {
|
||||
value: String(t.id), text: t.name, selected: t.id === teamID,
|
||||
})));
|
||||
select.addEventListener('change', () => {
|
||||
teamID = Number(select.value);
|
||||
data = null;
|
||||
freshKey = null;
|
||||
refresh();
|
||||
});
|
||||
return h('div', { class: 'card' }, h('h2', { text: 'Team' }), select);
|
||||
}
|
||||
|
||||
// --- overview --------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// The global team selector: a small control, once per layout (the desktop
|
||||
// sidebar and the mobile topbar each have their own button in index.html),
|
||||
// showing the current team's colour and name — or "All teams" — and opening a
|
||||
// sheet to switch. Shown only once there is more than one team to choose
|
||||
// between, the same rule every other team-aware control in this app follows;
|
||||
// see state.js's currentTeam() for why nobody with just one ever has to.
|
||||
|
||||
import { h, openSheet, closeSheet } from './ui.js';
|
||||
import { state, currentTeam, setSelectedTeam, onTeamChange } from './state.js';
|
||||
import { teamColorClass } from './format.js';
|
||||
|
||||
// Not a real team id (ids are positive), so it can never collide with one —
|
||||
// the value closeSheet resolves with for "All teams", distinct from the null
|
||||
// a dismissed sheet resolves with.
|
||||
const ALL_TEAMS = '__all__';
|
||||
|
||||
const buttons = () => [
|
||||
document.getElementById('team-selector'),
|
||||
document.getElementById('team-selector-mobile'),
|
||||
].filter(Boolean);
|
||||
|
||||
// init wires the buttons once, at boot. render (below) is what actually fills
|
||||
// them in and is called again by state.js whenever the selection changes.
|
||||
export function init() {
|
||||
for (const btn of buttons()) btn.addEventListener('click', open);
|
||||
onTeamChange(render);
|
||||
}
|
||||
|
||||
export function render() {
|
||||
const multiTeam = (state.teams || []).length > 1;
|
||||
const team = currentTeam();
|
||||
const label = team ? team.name : 'All teams';
|
||||
const dotClass = team ? `team-dot ${teamColorClass(team.id)}` : 'team-dot';
|
||||
for (const btn of buttons()) {
|
||||
btn.hidden = !multiTeam;
|
||||
btn.replaceChildren(
|
||||
h('span', { class: dotClass }),
|
||||
h('span', { class: 'team-selector-label', text: label }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
const teams = state.teams || [];
|
||||
openSheet(() => [
|
||||
h('h2', { class: 'sheet-title', text: 'Switch team' }),
|
||||
h('ul', { class: 'menu', role: 'menu' },
|
||||
h('li', {}, h('button', {
|
||||
class: 'menu-item', type: 'button', role: 'menuitemradio',
|
||||
'aria-checked': String(state.selectedTeamID == null),
|
||||
onclick: () => closeSheet(ALL_TEAMS),
|
||||
}, h('span', { class: 'team-dot' }), ' All teams')),
|
||||
teams.map((t) => h('li', {}, h('button', {
|
||||
class: 'menu-item', type: 'button', role: 'menuitemradio',
|
||||
'aria-checked': String(t.id === state.selectedTeamID),
|
||||
onclick: () => closeSheet(t.id),
|
||||
}, h('span', { class: `team-dot ${teamColorClass(t.id)}` }), ' ' + t.name))),
|
||||
),
|
||||
]).then((choice) => {
|
||||
if (choice == null) return; // dismissed: backdrop, escape, or cancel
|
||||
setSelectedTeam(choice === ALL_TEAMS ? null : choice);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user