Compare commits
2 Commits
e5b4df7c03
...
949d6595ba
| Author | SHA1 | Date | |
|---|---|---|---|
| 949d6595ba | |||
| 33356ca978 |
@@ -15,5 +15,5 @@ type: application
|
||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
||||
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||
# metadata and drives nothing.
|
||||
version: 0.30.0
|
||||
appVersion: "v0.30.0"
|
||||
version: 0.31.0
|
||||
appVersion: "v0.31.0"
|
||||
|
||||
@@ -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