Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/guides/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ runs in different ways; *Waiting* shows what hasn't become a run yet, and
None of these screens can change anything. Every control lives in the detail
pane, so answering a gate is built once rather than several times.

Each screen has its own place in the address - `#office`, `#graph`, `#when`,
`#waiting`, `#terminal` - so you can bookmark one, and Back takes you to the
screen you were on before. Choosing a screen moves the keyboard focus to its
heading, which is also what a screen reader reads out.

### 5. Open a run and answer a gate

Select a run. It opens in the detail pane.
Expand Down
108 changes: 103 additions & 5 deletions src/Loadout.Core/Teams/Daemon/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,16 @@

.sheet-buttons { display: flex; gap: 0.6rem; flex-wrap: wrap; }

/* Starting a team, making one, scheduling one, clearing runs out and how the
roles have done are all about the list of runs. Above the office, the
graph or the timeline they pushed the view itself most of a screen down. */
.pane[data-view]:not([data-view="list"]) :is(#starting, #doing, #clearing, #roles) { display: none; }

/* What this machine is set to belongs on the Settings page, and is moved
there when that page is first drawn. Until then it waits in the runs pane,
where showing it would put machine settings under the list of runs. */
section.pane > #machine { display: none; }

/* The questions are written with line breaks in them - a goal, then its
criteria one to a line - and were laid out by the browser's own box. */
.sheet .question { margin: 0; white-space: pre-line; overflow-wrap: anywhere; }
Expand Down Expand Up @@ -1531,6 +1541,11 @@
display: grid;
grid-template-columns: var(--rail) minmax(0, 1fr);
grid-template-areas: "rail head" "rail main";

/* The spare height goes to the content. Unsaid, a short view - Waiting,
Settings - shared it out between the rows, and the header row grew to
192 pixels over 69 of header, pushing everything under it down. */
grid-template-rows: auto minmax(0, 1fr);
align-items: start;
min-height: 100vh;
}
Expand Down Expand Up @@ -1623,6 +1638,7 @@
[data-presentation="rich"] body {
grid-template-columns: minmax(0, 1fr);
grid-template-areas: "rail" "head" "main";
grid-template-rows: auto auto minmax(0, 1fr);
}

[data-presentation="rich"] #shell {
Expand Down Expand Up @@ -2490,7 +2506,7 @@ <h2 id="runs">Runs on this machine</h2>
own form, which says so in its heading. And a failure comes back to the
page.
-->
<details class="start">
<details class="start" id="starting">
<summary>Run a team</summary>

<!--
Expand Down Expand Up @@ -2921,7 +2937,7 @@ <h2 id="planning-heading">Run a team on a schedule</h2>
dashboard refuses the POST, and a pane of controls that are always refused
is the fault this whole design keeps coming back to.
-->
<details class="start" id="settings" hidden>
<details class="start" id="machine" hidden>
<summary>What this machine is set to</summary>

<div class="fields">
Expand Down Expand Up @@ -4876,7 +4892,31 @@ <h2 id="detail-heading">Run</h2>
if (shown) { drawOpenScreens(shown); }
}

function showView(which) {
// What each view is called at the top of the pane. It read "Runs on this
// machine" over all of them, so switching view changed nothing a screen
// reader would say and nothing a glance would catch.
function headingFor(box) {
return {
office: "The office",
graph: "Who asked whom",
when: "When runs ran",
waiting: "Waiting to run",
terminal: "Terminal"
}[box] || "Runs on this machine";
}

// Which view the address names, or -1 for none of them.
function viewInAddress() {
var named = location.hash.replace(/^#/, "");

for (var i = 0; i < views.length; i++) {
if (views[i].box === named) { return i; }
}

return -1;
}

function showView(which, person) {
if (board.on) {
// Take the screens back off the board before showing one on its own.
board.on = false;
Expand All @@ -4903,7 +4943,36 @@ <h2 id="detail-heading">Run</h2>
// none of it belongs above a page of preferences.
onlySettings(views[which].box === "settings");

var heading = document.getElementById("runs");

if (heading) {
heading.textContent = headingFor(views[which].box);
heading.parentElement.setAttribute("data-view", views[which].box);
}

if (shown) { views[which].draw(shown); }

// Moved to when somebody chose it, and not when the page opens on it: a
// view that changes and leaves focus on the button it was chosen with is
// one a keyboard user has to go and find, and a screen reader says
// nothing about. Settings draws its heading above, so it exists by now.
if (person) {
var top = views[which].box === "settings"
? document.getElementById("settings-heading")
: heading;

if (top) {
top.setAttribute("tabindex", "-1");
heading = top;
heading.focus();
}
}

// The address says which view this is, so Back goes to the last one and a
// bookmark opens this one. The token stays where it is, in the query.
if (person && location.hash !== "#" + views[which].box) {
history.pushState(null, "", "#" + views[which].box);
}
}

// The runs as they were last drawn, so switching view does not have to wait
Expand Down Expand Up @@ -4973,7 +5042,7 @@ <h2 id="detail-heading">Run</h2>
(function () {
views.forEach(function (one, i) {
document.getElementById(one.button).addEventListener("click", function () {
showView(i);
showView(i, true);
});
});

Expand All @@ -4985,6 +5054,16 @@ <h2 id="detail-heading">Run</h2>
railUp();
plainly();
askTheMachine();

// Opened on the view the address names, and moved by Back and Forward.
// Neither takes the focus: nobody chose them on this page.
var named = viewInAddress();

if (named > 0) { showView(named, false); }

window.addEventListener("popstate", function () {
showView(Math.max(0, viewInAddress()), false);
});
}

if (solo) {
Expand Down Expand Up @@ -6569,6 +6648,20 @@ <h2 id="detail-heading">Run</h2>

if (prefs) { telling.appendChild(prefs); }

// ---- this machine ----
// Where notices go, what may start a run from outside, where this page
// listens and which office art it draws. It sat under the runs list, where
// nothing about it belonged, and is settings - of the machine rather than
// the screen, which is why it says so and types a command for each change.
// Moved rather than rebuilt, so its controls keep what they were wired to;
// fillSettings still decides whether it shows at all.
var machine = document.getElementById("machine");

if (machine) {
page.appendChild(machine);
machine.open = true;
}

// ---- what is not here ----
var elsewhere = group(page, "Everything else");

Expand Down Expand Up @@ -7776,7 +7869,12 @@ <h2 id="detail-heading">Run</h2>
*/
function fillSettings() {
return api("/api/settings").then(function (answer) {
var fold = document.getElementById("settings");
// Its own id. It was "settings", the same as the Settings page, and
// getElementById returns the first: this unhid the page rather than the
// fold, and where notices go, the trigger token, the listen address and
// the office art were out of reach from the dashboard from the day the
// Settings page arrived.
var fold = document.getElementById("machine");

if (!fold) { return; }

Expand Down
53 changes: 52 additions & 1 deletion tests/Loadout.Tests/Integration/DashboardServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1213,6 +1213,57 @@ public async Task An_office_with_no_art_still_holds_its_desks()
text.Should().NotContain(".desk .card");
}

[Fact]
public async Task Every_id_on_the_page_is_used_once()
{
// The Settings page and the "What this machine is set to" fold were both
// id="settings". getElementById returns the first, so the code meant to
// show the fold unhid the page instead, and where notices go, the
// trigger token, the listen address and the office art could not be
// reached from the dashboard at all.
var text = await (await GetAsync("/")).Content.ReadAsStringAsync();

var ids = System.Text.RegularExpressions.Regex.Matches(text, "\\sid=\"([^\"]+)\"")
.Select(match => match.Groups[1].Value)
.GroupBy(id => id)
.Where(group => group.Count() > 1)
.Select(group => group.Key);

ids.Should().BeEmpty("an id names one element");

// And the machine's settings wait out of sight until the Settings page
// takes them, rather than appearing under the list of runs.
text.Should().Contain("section.pane > #machine { display: none; }");
}

[Fact]
public async Task A_view_says_what_it_is_and_takes_the_focus()
{
// Switching view left focus on the button and the heading reading
// "Runs on this machine" over the office, the graph and the timeline,
// so somebody on a screen reader was told nothing had changed.
var text = await (await GetAsync("/")).Content.ReadAsStringAsync();

text.Should().Contain("function headingFor(box)");
text.Should().Contain("heading.textContent = headingFor(views[which].box);");
text.Should().Contain("top.setAttribute(\"tabindex\", \"-1\");");

// And the address says which view it is, so Back and a bookmark work.
text.Should().Contain("history.pushState(null, \"\", \"#\" + views[which].box);");
text.Should().Contain("window.addEventListener(\"popstate\"");
}

[Fact]
public async Task A_short_view_does_not_push_the_page_down()
{
// The page is a grid at least a window tall. With nothing saying which
// row takes the spare height, a short view - Waiting, Settings - shared
// it out, and the header row grew to 192 pixels over 69 of header.
var text = await (await GetAsync("/")).Content.ReadAsStringAsync();

text.Should().Contain("grid-template-rows: auto minmax(0, 1fr);");
}

[Fact]
public async Task A_row_of_choices_wraps_rather_than_widening_the_pane()
{
Expand Down Expand Up @@ -1709,7 +1760,7 @@ public async Task The_page_offers_a_way_to_start_one()

// Folded away, because the page is for watching what is already going
// and a form that is always open is one somebody fills in by accident.
text.Should().Contain("<details class=\"start\">");
text.Should().Contain("<details class=\"start\" id=\"starting\">");

// "Run a team", not "Start a team". The old heading sat above a form
// asking for a team name, what it was for and a project, and somebody
Expand Down
Loading