Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ee576f793 | |||
| b610b1817a | |||
| 949d6595ba | |||
| 33356ca978 |
@@ -15,5 +15,5 @@ type: application
|
|||||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
# 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
|
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||||
# metadata and drives nothing.
|
# metadata and drives nothing.
|
||||||
version: 0.30.0
|
version: 0.32.0
|
||||||
appVersion: "v0.30.0"
|
appVersion: "v0.32.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;
|
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 { padding-bottom: calc(var(--tabbar-h) + var(--safe-bottom)); }
|
||||||
.view-page { padding-left: 16px; padding-right: 16px; }
|
.view-page { padding-left: 16px; padding-right: 16px; }
|
||||||
.view-page > * { max-width: 760px; margin-left: auto; margin-right: auto; }
|
.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; }
|
.chips::-webkit-scrollbar { display: none; }
|
||||||
.chip {
|
.chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
flex: none;
|
flex: none;
|
||||||
min-height: 34px; padding: 0 12px;
|
min-height: 34px; padding: 0 12px;
|
||||||
border: 1px solid var(--border-strong); border-radius: 999px;
|
border: 1px solid var(--border-strong); border-radius: 999px;
|
||||||
@@ -611,6 +639,8 @@ details[open] > summary { margin-bottom: 8px; }
|
|||||||
.account-name { font-size: 18px; font-weight: 750; }
|
.account-name { font-size: 18px; font-weight: 750; }
|
||||||
.account-email { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; }
|
.account-email { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; }
|
||||||
.pw-form { display: grid; gap: 12px; padding: 16px; }
|
.pw-form { display: grid; gap: 12px; padding: 16px; }
|
||||||
|
.account-fold { border-top: 1px solid var(--border); padding-top: 12px; }
|
||||||
|
.account-fold .stacked-form { margin-top: 8px; }
|
||||||
.form-ok {
|
.form-ok {
|
||||||
margin: 0; padding: 10px 12px;
|
margin: 0; padding: 10px 12px;
|
||||||
background: var(--ok-soft); color: var(--ok);
|
background: var(--ok-soft); color: var(--ok);
|
||||||
@@ -645,6 +675,7 @@ kbd {
|
|||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
padding: 4px 10px 18px; font-size: 18px; font-weight: 750; letter-spacing: -0.01em;
|
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 {
|
.nav-link {
|
||||||
flex-direction: row; justify-content: flex-start; gap: 12px;
|
flex-direction: row; justify-content: flex-start; gap: 12px;
|
||||||
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
|
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
|
||||||
@@ -784,7 +815,6 @@ kbd {
|
|||||||
.stacked-form label { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; }
|
.stacked-form label { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; }
|
||||||
.stacked-form label.checkbox { gap: 8px; }
|
.stacked-form label.checkbox { gap: 8px; }
|
||||||
.stacked-form input.wide { min-width: min(420px, 100%); }
|
.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
|
/* 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
|
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">
|
<img src="/icon.svg" alt="" width="28" height="28">
|
||||||
<span>terdut</span>
|
<span>terdut</span>
|
||||||
</a>
|
</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">
|
<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>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg>
|
||||||
<span class="nav-label">Queue</span>
|
<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>
|
<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>
|
<span class="nav-badge menu-btn-badge" data-badge hidden></span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="team-selector-mobile" id="team-selector-mobile" type="button" hidden></button>
|
||||||
<h1 class="topbar-title" id="topbar-title">Queue</h1>
|
<h1 class="topbar-title" id="topbar-title">Queue</h1>
|
||||||
</div>
|
</div>
|
||||||
<span class="open-pill" id="open-pill" hidden></span>
|
<span class="open-pill" id="open-pill" hidden></span>
|
||||||
|
|||||||
@@ -43,9 +43,12 @@ function render() {
|
|||||||
//
|
//
|
||||||
// The topic is the whole address — the server it is published to is the
|
// The topic is the whole address — the server it is published to is the
|
||||||
// install's one ntfy, set in the deployment and not something a user picks.
|
// install's one ntfy, set in the deployment and not something a user picks.
|
||||||
|
function notifyStatus(topic) {
|
||||||
|
return topic ? `Topic: ${topic}` : 'No topic set — pages go to the team’s fallback topic.';
|
||||||
|
}
|
||||||
|
|
||||||
function notifyForm(user) {
|
function notifyForm(user) {
|
||||||
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||||||
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
|
||||||
const topic = h('input', {
|
const topic = h('input', {
|
||||||
name: 'ntfy_topic', type: 'text', autocomplete: 'off',
|
name: 'ntfy_topic', type: 'text', autocomplete: 'off',
|
||||||
autocapitalize: 'none', spellcheck: false,
|
autocapitalize: 'none', spellcheck: false,
|
||||||
@@ -54,29 +57,7 @@ function notifyForm(user) {
|
|||||||
});
|
});
|
||||||
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' });
|
const submit = h('button', { class: 'btn btn-primary', type: 'submit', text: 'Save topic' });
|
||||||
|
|
||||||
// Only offered once a topic is saved: the test publishes to whatever the
|
const form = h('form', { class: 'stacked-form' },
|
||||||
// server has stored, not to whatever is half-typed in the field.
|
|
||||||
const test = h('button', {
|
|
||||||
class: 'btn', type: 'button', text: 'Send a test push',
|
|
||||||
hidden: !user.ntfy_topic,
|
|
||||||
onclick: async () => {
|
|
||||||
err.hidden = true;
|
|
||||||
ok.hidden = true;
|
|
||||||
test.disabled = true;
|
|
||||||
try {
|
|
||||||
await api.testNotification();
|
|
||||||
ok.textContent = 'Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.';
|
|
||||||
ok.hidden = false;
|
|
||||||
} catch (ex) {
|
|
||||||
err.textContent = ex.message;
|
|
||||||
err.hidden = false;
|
|
||||||
} finally {
|
|
||||||
test.disabled = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = h('form', { class: 'card pw-form' },
|
|
||||||
h('label', {},
|
h('label', {},
|
||||||
h('span', { text: 'ntfy topic' }),
|
h('span', { text: 'ntfy topic' }),
|
||||||
topic),
|
topic),
|
||||||
@@ -89,25 +70,46 @@ function notifyForm(user) {
|
|||||||
h('p', { class: 'muted small' },
|
h('p', { class: 'muted small' },
|
||||||
'Anyone who knows the topic can read your pages and publish to it, so ',
|
'Anyone who knows the topic can read your pages and publish to it, so ',
|
||||||
'pick something unguessable rather than your name.'),
|
'pick something unguessable rather than your name.'),
|
||||||
err, ok,
|
err,
|
||||||
h('div', { class: 'row-actions' }, submit, test),
|
submit,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const status = h('p', { class: 'muted', text: notifyStatus(user.ntfy_topic) });
|
||||||
|
const summary = h('summary', { text: user.ntfy_topic ? 'Change topic' : 'Set a topic' });
|
||||||
|
const details = h('details', { class: 'account-fold' }, summary, form);
|
||||||
|
|
||||||
|
// Only offered once a topic is saved: the test publishes to whatever the
|
||||||
|
// server has stored, not to whatever is half-typed in the field.
|
||||||
|
const test = h('button', {
|
||||||
|
class: 'btn', type: 'button', text: 'Send a test push',
|
||||||
|
hidden: !user.ntfy_topic,
|
||||||
|
onclick: async () => {
|
||||||
|
test.disabled = true;
|
||||||
|
try {
|
||||||
|
await api.testNotification();
|
||||||
|
toast('Sent. If nothing arrives, the topic is wrong or ntfy is not reachable.');
|
||||||
|
} catch (ex) {
|
||||||
|
toast(ex.message, 'error');
|
||||||
|
} finally {
|
||||||
|
test.disabled = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
form.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
err.hidden = true;
|
err.hidden = true;
|
||||||
ok.hidden = true;
|
|
||||||
submit.disabled = true;
|
submit.disabled = true;
|
||||||
try {
|
try {
|
||||||
const updated = await api.setNotifyTarget(user.id, topic.value.trim());
|
const updated = await api.setNotifyTarget(user.id, topic.value.trim());
|
||||||
// Keep the cached user in step, so the onboarding checklist stops
|
// Keep the cached user in step, so the onboarding checklist stops
|
||||||
// asking for this and the test button appears without a reload.
|
// asking for this and the test button appears without a reload.
|
||||||
state.me.user = updated;
|
state.me.user = updated;
|
||||||
ok.textContent = updated.ntfy_topic
|
status.textContent = notifyStatus(updated.ntfy_topic);
|
||||||
? 'Topic saved.'
|
summary.textContent = updated.ntfy_topic ? 'Change topic' : 'Set a topic';
|
||||||
: 'Topic cleared. Your pages go to the team’s fallback topic.';
|
|
||||||
ok.hidden = false;
|
|
||||||
test.hidden = !updated.ntfy_topic;
|
test.hidden = !updated.ntfy_topic;
|
||||||
|
details.open = false;
|
||||||
|
toast(updated.ntfy_topic ? 'Topic saved' : 'Topic cleared. Your pages go to the team’s fallback topic.');
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
err.textContent = ex.message;
|
err.textContent = ex.message;
|
||||||
err.hidden = false;
|
err.hidden = false;
|
||||||
@@ -115,7 +117,11 @@ function notifyForm(user) {
|
|||||||
submit.disabled = false;
|
submit.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return form;
|
|
||||||
|
return h('div', { class: 'card pw-form' },
|
||||||
|
status,
|
||||||
|
h('div', { class: 'row-actions' }, test),
|
||||||
|
details);
|
||||||
}
|
}
|
||||||
|
|
||||||
// With password login switched off a password opens nothing, so somebody who
|
// With password login switched off a password opens nothing, so somebody who
|
||||||
@@ -131,14 +137,13 @@ function passwordSection(user, hasPassword) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
h('div', { class: 'page-head' }, h('h2', { text: hasPassword ? 'Change password' : 'Set a password' })),
|
h('div', { class: 'page-head' }, h('h2', { text: 'Password' })),
|
||||||
passwordForm(user, hasPassword),
|
passwordForm(user, hasPassword),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function passwordForm(user, hasPassword) {
|
function passwordForm(user, hasPassword) {
|
||||||
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
const err = h('p', { class: 'form-error', role: 'alert', hidden: true });
|
||||||
const ok = h('p', { class: 'form-ok', role: 'status', hidden: true });
|
|
||||||
const current = hasPassword
|
const current = hasPassword
|
||||||
? h('input', { name: 'current', type: 'password', autocomplete: 'current-password', required: true })
|
? h('input', { name: 'current', type: 'password', autocomplete: 'current-password', required: true })
|
||||||
: null;
|
: null;
|
||||||
@@ -148,18 +153,24 @@ function passwordForm(user, hasPassword) {
|
|||||||
|
|
||||||
// A hidden username field lets password managers file the new password
|
// A hidden username field lets password managers file the new password
|
||||||
// under the right account.
|
// under the right account.
|
||||||
const form = h('form', { class: 'card pw-form', autocomplete: 'on' },
|
const form = h('form', { class: 'stacked-form', autocomplete: 'on' },
|
||||||
h('input', { type: 'text', name: 'username', autocomplete: 'username', value: user.username, hidden: true, readonly: true }),
|
h('input', { type: 'text', name: 'username', autocomplete: 'username', value: user.username, hidden: true, readonly: true }),
|
||||||
current && h('label', {}, h('span', { text: 'Current password' }), current),
|
current && h('label', {}, h('span', { text: 'Current password' }), current),
|
||||||
h('label', {}, h('span', { text: 'New password' }), next),
|
h('label', {}, h('span', { text: 'New password' }), next),
|
||||||
h('label', {}, h('span', { text: 'Repeat new password' }), again),
|
h('label', {}, h('span', { text: 'Repeat new password' }), again),
|
||||||
err, ok, submit,
|
err, submit,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const status = h('p', {
|
||||||
|
class: 'muted',
|
||||||
|
text: hasPassword ? 'Password set.' : 'No password set — sign-in needs one of the other methods.',
|
||||||
|
});
|
||||||
|
const summary = h('summary', { text: hasPassword ? 'Change password' : 'Set a password' });
|
||||||
|
const details = h('details', { class: 'account-fold' }, summary, form);
|
||||||
|
|
||||||
form.addEventListener('submit', async (e) => {
|
form.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
err.hidden = true;
|
err.hidden = true;
|
||||||
ok.hidden = true;
|
|
||||||
if (next.value !== again.value) {
|
if (next.value !== again.value) {
|
||||||
err.textContent = 'The new passwords do not match.';
|
err.textContent = 'The new passwords do not match.';
|
||||||
err.hidden = false;
|
err.hidden = false;
|
||||||
@@ -171,13 +182,14 @@ function passwordForm(user, hasPassword) {
|
|||||||
state.me.has_password = true;
|
state.me.has_password = true;
|
||||||
form.reset();
|
form.reset();
|
||||||
if (!current) {
|
if (!current) {
|
||||||
// From now on the form needs the current-password field.
|
// From now on the form needs the current-password field, and a fresh
|
||||||
|
// render already comes up with the fold closed.
|
||||||
render();
|
render();
|
||||||
toast('Password saved');
|
toast('Password saved');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ok.textContent = 'Password saved. Other devices have been signed out.';
|
details.open = false;
|
||||||
ok.hidden = false;
|
toast('Password saved. Other devices have been signed out.');
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
err.textContent = ex.message;
|
err.textContent = ex.message;
|
||||||
err.hidden = false;
|
err.hidden = false;
|
||||||
@@ -185,7 +197,7 @@ function passwordForm(user, hasPassword) {
|
|||||||
submit.disabled = false;
|
submit.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return form;
|
return h('div', { class: 'card pw-form' }, status, details);
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortcuts() {
|
function shortcuts() {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import * as alerts from './alerts.js';
|
|||||||
import * as stats from './stats.js';
|
import * as stats from './stats.js';
|
||||||
import * as account from './account.js';
|
import * as account from './account.js';
|
||||||
import * as team from './team.js';
|
import * as team from './team.js';
|
||||||
|
import * as teamselector from './teamselector.js';
|
||||||
import * as admin from './admin.js';
|
import * as admin from './admin.js';
|
||||||
import * as adminuser from './adminuser.js';
|
import * as adminuser from './adminuser.js';
|
||||||
import * as adminteam from './adminteam.js';
|
import * as adminteam from './adminteam.js';
|
||||||
@@ -230,6 +231,7 @@ async function boot() {
|
|||||||
$('login-form').addEventListener('submit', onLogin);
|
$('login-form').addEventListener('submit', onLogin);
|
||||||
$('signup-form').addEventListener('submit', onSignup);
|
$('signup-form').addEventListener('submit', onSignup);
|
||||||
$('menu-btn').addEventListener('click', openNavMenu);
|
$('menu-btn').addEventListener('click', openNavMenu);
|
||||||
|
teamselector.init();
|
||||||
ssoErrorCode = takeSSOError();
|
ssoErrorCode = takeSSOError();
|
||||||
|
|
||||||
// /signup is the one route that works without a session.
|
// /signup is the one route that works without a session.
|
||||||
@@ -245,6 +247,7 @@ async function boot() {
|
|||||||
await loadAuthConfig();
|
await loadAuthConfig();
|
||||||
state.me = await api.me();
|
state.me = await api.me();
|
||||||
await loadTeams();
|
await loadTeams();
|
||||||
|
teamselector.render();
|
||||||
// The Admin tab exists only for an administrator. Somebody who types /admin
|
// 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.
|
// anyway gets the view's own "ask an administrator" card, not a blank page.
|
||||||
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
||||||
@@ -328,6 +331,7 @@ async function onSignup(e) {
|
|||||||
history.replaceState({ depth: 0 }, '', '/');
|
history.replaceState({ depth: 0 }, '', '/');
|
||||||
route = parseRoute('/');
|
route = parseRoute('/');
|
||||||
await loadTeams();
|
await loadTeams();
|
||||||
|
teamselector.render();
|
||||||
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
$('nav-admin').hidden = !state.me?.user?.is_admin;
|
||||||
showApp();
|
showApp();
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
|
|||||||
@@ -98,6 +98,14 @@ export function severityClass(sev) {
|
|||||||
return '';
|
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.
|
// A one-line summary of the group labels, without the one the title already shows.
|
||||||
export function labelSummary(labels, skip = 'alertname') {
|
export function labelSummary(labels, skip = 'alertname') {
|
||||||
return Object.entries(labels || {})
|
return Object.entries(labels || {})
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import * as api from './api.js';
|
import * as api from './api.js';
|
||||||
import { h, clear, badge, emptyState, spinner } from './ui.js';
|
import { h, clear, badge, emptyState, spinner } from './ui.js';
|
||||||
import { age, until, isFuture, severityClass, labelSummary } from './format.js';
|
import { age, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js';
|
||||||
import { state, myID } from './state.js';
|
import { state, myID, setSelectedTeam, onTeamChange } from './state.js';
|
||||||
import * as onboarding from './onboarding.js';
|
import * as onboarding from './onboarding.js';
|
||||||
import { navigate } from './app.js';
|
import { navigate } from './app.js';
|
||||||
|
|
||||||
@@ -27,34 +27,21 @@ const EMPTY = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onboarding.onRerender(() => renderList());
|
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 filter = loadFilter();
|
||||||
let teamFilter = loadTeamFilter(); // '' for every team the viewer is in
|
|
||||||
let items = null; // null while loading
|
let items = null; // null while loading
|
||||||
let error = null;
|
let error = null;
|
||||||
let selected = null;
|
let selected = null;
|
||||||
let cursor = -1; // keyboard position in the list
|
let cursor = -1; // keyboard position in the list
|
||||||
let built = false;
|
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() {
|
function loadFilter() {
|
||||||
try {
|
try {
|
||||||
const f = sessionStorage.getItem('terdut.queue.filter');
|
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 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
|
// The cached open queue covers every team, so it can only be reused when
|
||||||
// no team filter is applied.
|
// no team filter is applied.
|
||||||
const query = teamFilter ? { ...f.query, team_id: teamFilter } : f.query;
|
const query = state.selectedTeamID != null ? { ...f.query, team_id: state.selectedTeamID } : f.query;
|
||||||
const cached = filter === 'open' && !fresh && !teamFilter;
|
const cached = filter === 'open' && !fresh && state.selectedTeamID == null;
|
||||||
const result = cached ? state.open : await api.incidents(query);
|
const result = cached ? state.open : await api.incidents(query);
|
||||||
await onboarding.load();
|
await onboarding.load();
|
||||||
if (requested !== filter) return;
|
if (requested !== filter) return;
|
||||||
@@ -136,19 +123,20 @@ function renderChips() {
|
|||||||
class: 'chip',
|
class: 'chip',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
role: 'tab',
|
role: 'tab',
|
||||||
'aria-selected': String(teamFilter === ''),
|
'aria-selected': String(state.selectedTeamID == null),
|
||||||
onclick: () => setTeamFilter(''),
|
onclick: () => setSelectedTeam(null),
|
||||||
text: 'All teams',
|
}, h('span', { class: 'team-dot' }), ' All teams'));
|
||||||
}));
|
|
||||||
for (const team of state.teams) {
|
for (const team of state.teams) {
|
||||||
chips.push(h('button', {
|
chips.push(h('button', {
|
||||||
class: 'chip',
|
class: 'chip',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
role: 'tab',
|
role: 'tab',
|
||||||
'aria-selected': String(teamFilter === String(team.id)),
|
'aria-selected': String(team.id === state.selectedTeamID),
|
||||||
onclick: () => setTeamFilter(String(team.id)),
|
onclick: () => setSelectedTeam(team.id),
|
||||||
text: team.name,
|
},
|
||||||
}));
|
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: '' } },
|
auth: { password_login: true, oidc: { enabled: false, name: '' } },
|
||||||
open: [], // the default queue: open, not snoozed
|
open: [], // the default queue: open, not snoozed
|
||||||
teams: [], // the teams the viewer belongs to, each with their role
|
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 —
|
const SELECTED_TEAM_KEY = 'terdut.selectedTeam';
|
||||||
// which is everybody until somebody makes a second — never has to choose.
|
|
||||||
|
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() {
|
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() {
|
export function myID() {
|
||||||
@@ -36,6 +77,12 @@ export async function users() {
|
|||||||
|
|
||||||
export async function loadTeams() {
|
export async function loadTeams() {
|
||||||
state.teams = await api.teams();
|
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;
|
return state.teams;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
import * as api from './api.js';
|
import * as api from './api.js';
|
||||||
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip, ssoBadge, SSO_MANAGED } from './ui.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';
|
import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js';
|
||||||
|
|
||||||
const view = () => document.getElementById('view-team');
|
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' },
|
{ 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;
|
let teamID = null;
|
||||||
// Which sub-section is open. Remembered rather than passed, because the poll
|
// Which sub-section is open. Remembered rather than passed, because the poll
|
||||||
// loop calls refresh() with no route.
|
// 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 error = null;
|
||||||
let freshKey = null; // an integration key, shown once, until the view is left
|
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) {
|
export function show(route) {
|
||||||
const next = route?.tab ?? null;
|
const next = route?.tab ?? null;
|
||||||
// A different sub-section wants different data, so the old answer goes
|
// A different sub-section wants different data, so the old answer goes
|
||||||
@@ -61,13 +71,8 @@ export function show(route) {
|
|||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedTeam() {
|
|
||||||
const teams = state.teams || [];
|
|
||||||
return teams.find((t) => t.id === teamID) || currentTeam();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function refresh() {
|
export async function refresh() {
|
||||||
const team = selectedTeam();
|
const team = currentTeam();
|
||||||
if (!team) {
|
if (!team) {
|
||||||
data = null;
|
data = null;
|
||||||
render();
|
render();
|
||||||
@@ -172,25 +177,13 @@ function subnav() {
|
|||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only shown to somebody in more than one team, like the queue's filter chips.
|
// Names which team's settings the six sections below belong to. It used to be
|
||||||
// It is above the sections rather than inside one because it changes the
|
// a picker of its own for somebody in more than one team; that job now belongs
|
||||||
// subject of all six.
|
// to the global team selector in the nav, which is what onTeamChange above
|
||||||
|
// reacts to.
|
||||||
function teamPicker() {
|
function teamPicker() {
|
||||||
if ((state.teams || []).length < 2) {
|
|
||||||
return h('div', { class: 'card' }, h('h2', { text: data.team.name }));
|
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 --------------------------------------------------------------
|
// --- 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