Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ca65a845f | ||
|
|
e383070a9e | ||
|
|
2af7ef27ba | ||
|
|
5107b06f8c |
@@ -273,7 +273,14 @@ below. Nothing is installed without someone clicking.
|
|||||||
place and shows nothing, then appears when it is; it is never replaced by
|
place and shows nothing, then appears when it is; it is never replaced by
|
||||||
the module's default tile. Saving over an existing name asks first, and
|
the module's default tile. Saving over an existing name asks first, and
|
||||||
the server keeps the copy it replaced: **Restore previous** in Admin →
|
the server keeps the copy it replaced: **Restore previous** in Admin →
|
||||||
Saved layouts swaps it back (and swapping again undoes that).
|
Saved layouts swaps it back (and swapping again undoes that). The menu's
|
||||||
|
**Grid Size** stepper sets how many columns this browser cuts the board into —
|
||||||
|
8 to 24, 12 by default. Finer lets tiles be smaller so more fit across (a
|
||||||
|
4K wall), coarser suits a small screen read close up; row height follows,
|
||||||
|
so tiles keep their shape and simply change size. Going coarser, a tile
|
||||||
|
that no longer fits where it was moves to the first free spot rather than
|
||||||
|
onto another tile. It saves with the layout, so a named layout comes back
|
||||||
|
at the density it was arranged on.
|
||||||
- Every tile has a **status dot**: green = live, amber = connecting,
|
- Every tile has a **status dot**: green = live, amber = connecting,
|
||||||
red = upstream unreachable (hover it for details). Tiles reconnect
|
red = upstream unreachable (hover it for details). Tiles reconnect
|
||||||
automatically.
|
automatically.
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ the app); its settings are kept for a later reinstall.
|
|||||||
| `client` | yes | Client entry file, loaded as an ES module. |
|
| `client` | yes | Client entry file, loaded as an ES module. |
|
||||||
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
|
| `server` | no | Server entry file (CommonJS), loaded with `require()`. |
|
||||||
| `style` | no | A stylesheet the shell injects once per module. |
|
| `style` | no | A stylesheet the shell injects once per module. |
|
||||||
| `minSize`, `defaultSize` | no | Tile size in grid cells (12 columns wide; rows are ~72 px). Defaults: min 1×1, default 4×3. |
|
| `minSize`, `defaultSize` | no | Tile size in grid cells, declared for the default grid (12 columns, 72 px rows). Defaults: min 1×1, default 4×3. A dashboard may cut its board finer or coarser (8–24 columns, from the Layouts menu; row height follows). `defaultSize` is scaled to that density when a tile is added, so a new tile lands at about the size you meant; `minSize` is enforced in cells as declared, so on a finer grid a tile can be made smaller than it could at 12. |
|
||||||
| `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. |
|
| `configSchema` | no | **Admin config** — server-wide settings (URLs, ports, keys) edited once at `/admin` for everyone. |
|
||||||
| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). They apply as they are changed, so keep them to display choices. |
|
| `instanceSchema` | no | **Per-tile settings** — chosen in each tile's gear menu and stored with that browser's layout (filters, text sizes, display options). They apply as they are changed, so keep them to display choices. |
|
||||||
| `tiles` | no | Fixed multi-tile list for client-only modules; server modules export a dynamic `tiles()` instead. See **Presenting multiple tiles**. |
|
| `tiles` | no | Fixed multi-tile list for client-only modules; server modules export a dynamic `tiles()` instead. See **Presenting multiple tiles**. |
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.8.0",
|
"version": "1.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
|||||||
+126
-7
@@ -5,7 +5,18 @@
|
|||||||
settings) autosaves to this browser's localStorage — every browser is fully
|
settings) autosaves to this browser's localStorage — every browser is fully
|
||||||
independent. Modules render inside tile bodies and never touch the shell. */
|
independent. Modules render inside tile bodies and never touch the shell. */
|
||||||
|
|
||||||
const COLS = 12;
|
/* ── grid density ─────────────────────────────────────────────────────
|
||||||
|
How many columns the board is cut into. Twelve is the default; a finer
|
||||||
|
grid lets tiles be smaller and more of them fit across (a 4K wall), a
|
||||||
|
coarser one suits a small screen read close up. It is this browser's
|
||||||
|
choice — two displays at one location may want different densities — so
|
||||||
|
it saves with the layout, local and named, and a named layout comes back
|
||||||
|
at the density it was arranged on. Row height follows the column width, so
|
||||||
|
cells keep their shape: a 3×2 tile is still a 3×2 tile, only smaller. */
|
||||||
|
const GRID_STEPS = [8, 10, 12, 16, 20, 24];
|
||||||
|
const GRID_DEFAULT = 12;
|
||||||
|
const CELL_H_DEFAULT = 72; // px per row at the default density
|
||||||
|
let COLS = GRID_DEFAULT;
|
||||||
const LS_LAYOUT = 'proddash:layout';
|
const LS_LAYOUT = 'proddash:layout';
|
||||||
|
|
||||||
const grid = document.getElementById('grid');
|
const grid = document.getElementById('grid');
|
||||||
@@ -224,7 +235,7 @@ const LS_LAYOUT_PREVIOUS = LS_LAYOUT + '.previous';
|
|||||||
|
|
||||||
function saveLayout() {
|
function saveLayout() {
|
||||||
try {
|
try {
|
||||||
const next = JSON.stringify({ v: 1, autoHideHeads, tiles });
|
const next = JSON.stringify({ v: 1, autoHideHeads, gridCols: COLS, tiles });
|
||||||
const current = localStorage.getItem(LS_LAYOUT);
|
const current = localStorage.getItem(LS_LAYOUT);
|
||||||
if (current && current !== next) localStorage.setItem(LS_LAYOUT_BACKUP, current);
|
if (current && current !== next) localStorage.setItem(LS_LAYOUT_BACKUP, current);
|
||||||
localStorage.setItem(LS_LAYOUT, next);
|
localStorage.setItem(LS_LAYOUT, next);
|
||||||
@@ -275,6 +286,87 @@ function newId() {
|
|||||||
|
|
||||||
/* ── grid geometry & collision ──────────────────────────────────────── */
|
/* ── grid geometry & collision ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
function normalizeGridCols(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
return GRID_STEPS.includes(n) ? n : GRID_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellHeightFor(cols) {
|
||||||
|
return Math.round((CELL_H_DEFAULT * GRID_DEFAULT) / cols);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cut the board into `cols` columns. Tiles keep their grid units — the
|
||||||
|
board gets denser or coarser and everything on it changes size with it.
|
||||||
|
A tile now past the right edge is pulled back in, and one that would then
|
||||||
|
sit on another moves to the first free spot instead. */
|
||||||
|
function setGridCols(value, { save = true } = {}) {
|
||||||
|
const next = normalizeGridCols(value);
|
||||||
|
const changed = next !== COLS;
|
||||||
|
COLS = next;
|
||||||
|
grid.style.gridTemplateColumns = `repeat(${COLS}, minmax(0, 1fr))`;
|
||||||
|
document.documentElement.style.setProperty('--cell-h', cellHeightFor(COLS) + 'px');
|
||||||
|
if (changed) {
|
||||||
|
// A coarser board may not have room for every tile where it was. Settle
|
||||||
|
// them in reading order: pull a tile past the right edge back in, and
|
||||||
|
// give one that would then land on a tile already settled the first free
|
||||||
|
// spot instead — tiles never overlap here, any more than under a drag.
|
||||||
|
const settled = [];
|
||||||
|
for (const tile of tiles.slice().sort((a, b) => a.y - b.y || a.x - b.x)) {
|
||||||
|
tile.w = Math.max(1, Math.min(COLS, tile.w));
|
||||||
|
tile.x = Math.max(0, Math.min(COLS - tile.w, tile.x));
|
||||||
|
if (settled.some((s) => overlaps(tile, s))) Object.assign(tile, spotAmong(tile.w, tile.h, settled));
|
||||||
|
settled.push(tile);
|
||||||
|
applyRect(tile);
|
||||||
|
notifyResize(tile);
|
||||||
|
}
|
||||||
|
rebuildDividers();
|
||||||
|
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
|
||||||
|
}
|
||||||
|
if (save) saveLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_MINUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M5 12h14"/></svg>';
|
||||||
|
const STEP_PLUS_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>';
|
||||||
|
|
||||||
|
/** The Layouts menu's density row — Grid Size − 12 + — which steps without
|
||||||
|
closing the menu, so the board can be watched changing behind it. */
|
||||||
|
function gridStepper() {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'menu-stepper';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'menu-stepper-label';
|
||||||
|
label.textContent = 'Grid Size';
|
||||||
|
const minus = document.createElement('button');
|
||||||
|
minus.type = 'button';
|
||||||
|
minus.className = 'menu-stepper-btn';
|
||||||
|
minus.setAttribute('data-tip', 'Coarser');
|
||||||
|
minus.innerHTML = STEP_MINUS_SVG;
|
||||||
|
const value = document.createElement('span');
|
||||||
|
value.className = 'menu-stepper-value';
|
||||||
|
const plus = document.createElement('button');
|
||||||
|
plus.type = 'button';
|
||||||
|
plus.className = 'menu-stepper-btn';
|
||||||
|
plus.setAttribute('data-tip', 'Finer');
|
||||||
|
plus.innerHTML = STEP_PLUS_SVG;
|
||||||
|
const paint = () => {
|
||||||
|
const i = GRID_STEPS.indexOf(COLS);
|
||||||
|
value.textContent = String(COLS);
|
||||||
|
minus.disabled = i <= 0;
|
||||||
|
plus.disabled = i >= GRID_STEPS.length - 1;
|
||||||
|
};
|
||||||
|
const step = (dir) => {
|
||||||
|
const i = GRID_STEPS.indexOf(COLS) + dir;
|
||||||
|
if (i < 0 || i >= GRID_STEPS.length) return;
|
||||||
|
setGridCols(GRID_STEPS[i]);
|
||||||
|
paint();
|
||||||
|
};
|
||||||
|
minus.addEventListener('click', () => step(-1));
|
||||||
|
plus.addEventListener('click', () => step(1));
|
||||||
|
paint();
|
||||||
|
row.append(label, minus, value, plus);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
function cellMetrics() {
|
function cellMetrics() {
|
||||||
const style = getComputedStyle(grid);
|
const style = getComputedStyle(grid);
|
||||||
const gapRaw = parseFloat(style.columnGap);
|
const gapRaw = parseFloat(style.columnGap);
|
||||||
@@ -297,6 +389,17 @@ function isFree(rect, excludeId) {
|
|||||||
return isFreeExcept(rect, [excludeId]);
|
return isFreeExcept(rect, [excludeId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** First spot, scanning top-to-bottom, left-to-right, clear of these tiles. */
|
||||||
|
function spotAmong(w, h, others) {
|
||||||
|
for (let y = 0; y < 400; y += 1) {
|
||||||
|
for (let x = 0; x <= COLS - w; x += 1) {
|
||||||
|
const rect = { x, y, w, h };
|
||||||
|
if (!others.some((o) => overlaps(rect, o))) return { x, y };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { x: 0, y: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
/** First free spot scanning top-to-bottom, left-to-right. */
|
/** First free spot scanning top-to-bottom, left-to-right. */
|
||||||
function findSpot(w, h) {
|
function findSpot(w, h) {
|
||||||
for (let y = 0; y < 200; y += 1) {
|
for (let y = 0; y < 200; y += 1) {
|
||||||
@@ -990,10 +1093,14 @@ function addTile(moduleId, entry = null) {
|
|||||||
toast(`${entry.name} is already on this dashboard`);
|
toast(`${entry.name} is already on this dashboard`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Sizes are declared for the default twelve-column grid; scaled so a new
|
||||||
|
// tile lands at about the size its author meant, whatever the density here.
|
||||||
|
const scale = COLS / GRID_DEFAULT;
|
||||||
const defaultSize = entry?.defaultSize || man?.defaultSize;
|
const defaultSize = entry?.defaultSize || man?.defaultSize;
|
||||||
|
const floor = minSizeOf({ module: moduleId, minSize: entry?.minSize });
|
||||||
const size = {
|
const size = {
|
||||||
w: Math.max(1, Math.min(COLS, defaultSize?.w || 4)),
|
w: Math.max(floor.w, 1, Math.min(COLS, Math.round((defaultSize?.w || 4) * scale))),
|
||||||
h: Math.max(1, defaultSize?.h || 3),
|
h: Math.max(floor.h, 1, Math.round((defaultSize?.h || 3) * scale)),
|
||||||
};
|
};
|
||||||
const spot = findSpot(size.w, size.h);
|
const spot = findSpot(size.w, size.h);
|
||||||
const settings = {};
|
const settings = {};
|
||||||
@@ -1991,6 +2098,8 @@ function renderLayout(layout) {
|
|||||||
// "reset" can simply hand over no tiles.
|
// "reset" can simply hand over no tiles.
|
||||||
const list = Array.isArray(layout) ? layout : (layout?.tiles || []);
|
const list = Array.isArray(layout) ? layout : (layout?.tiles || []);
|
||||||
clearAllTiles();
|
clearAllTiles();
|
||||||
|
// Density first: the tiles are clamped and placed against it.
|
||||||
|
if (!Array.isArray(layout)) setGridCols(layout?.gridCols ?? GRID_DEFAULT, { save: false });
|
||||||
tiles = list.map(sanitizeTile);
|
tiles = list.map(sanitizeTile);
|
||||||
if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
|
if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
|
||||||
for (const tile of tiles) if (tileIsOffered(tile)) buildTile(tile);
|
for (const tile of tiles) if (tileIsOffered(tile)) buildTile(tile);
|
||||||
@@ -2057,6 +2166,13 @@ function renderAddMenu(menu) {
|
|||||||
wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||||||
menu.innerHTML = '';
|
menu.innerHTML = '';
|
||||||
|
|
||||||
|
// Density first: the one thing in here that is not a layout but the board
|
||||||
|
// the layouts are arranged on.
|
||||||
|
menu.appendChild(gridStepper());
|
||||||
|
const stepperDivider = document.createElement('div');
|
||||||
|
stepperDivider.className = 'menu-divider';
|
||||||
|
menu.appendChild(stepperDivider);
|
||||||
|
|
||||||
menu.appendChild(menuItem('Save as named layout…', async () => {
|
menu.appendChild(menuItem('Save as named layout…', async () => {
|
||||||
menu.hidden = true;
|
menu.hidden = true;
|
||||||
if (!tiles.length) {
|
if (!tiles.length) {
|
||||||
@@ -2088,7 +2204,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
|||||||
const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
|
const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ layout: { autoHideHeads, tiles } }),
|
body: JSON.stringify({ layout: { autoHideHeads, gridCols: COLS, tiles } }),
|
||||||
});
|
});
|
||||||
const body = await res.json().catch(() => ({}));
|
const body = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
|
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
|
||||||
@@ -2136,15 +2252,17 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
|||||||
// does not live-link browsers together.
|
// does not live-link browsers together.
|
||||||
let outgoing = null;
|
let outgoing = null;
|
||||||
try { outgoing = localStorage.getItem(LS_LAYOUT); } catch { /* fine */ }
|
try { outgoing = localStorage.getItem(LS_LAYOUT); } catch { /* fine */ }
|
||||||
|
const gridBefore = COLS;
|
||||||
renderLayout(body.layout);
|
renderLayout(body.layout);
|
||||||
saveLayout();
|
saveLayout();
|
||||||
try { if (outgoing) localStorage.setItem(LS_LAYOUT_PREVIOUS, outgoing); } catch { /* fine */ }
|
try { if (outgoing) localStorage.setItem(LS_LAYOUT_PREVIOUS, outgoing); } catch { /* fine */ }
|
||||||
const unshown = unshownTileNames();
|
const unshown = unshownTileNames();
|
||||||
const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`;
|
const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`;
|
||||||
|
const gridNote = COLS !== gridBefore ? ` on a ${COLS}-column grid` : '';
|
||||||
const note = unshown.length
|
const note = unshown.length
|
||||||
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now`
|
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now`
|
||||||
: '';
|
: '';
|
||||||
toast(`Loaded “${l.name}”: ${count}${note}`, false, {
|
toast(`Loaded “${l.name}”: ${count}${gridNote}${note}`, false, {
|
||||||
ms: unshown.length ? 10000 : 7000,
|
ms: unshown.length ? 10000 : 7000,
|
||||||
action: outgoing ? {
|
action: outgoing ? {
|
||||||
label: 'Undo',
|
label: 'Undo',
|
||||||
@@ -2300,7 +2418,8 @@ function applyFullscreenFit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearFullscreenFit() {
|
function clearFullscreenFit() {
|
||||||
grid.style.gridTemplateColumns = '';
|
// Back to this browser's density, not the stylesheet's twelve.
|
||||||
|
grid.style.gridTemplateColumns = `repeat(${COLS}, minmax(0, 1fr))`;
|
||||||
grid.style.gridAutoRows = '';
|
grid.style.gridAutoRows = '';
|
||||||
gridwrap.classList.remove('no-scroll');
|
gridwrap.classList.remove('no-scroll');
|
||||||
for (const tile of tiles) {
|
for (const tile of tiles) {
|
||||||
|
|||||||
@@ -187,6 +187,36 @@ body.menu-hidden #topbar {
|
|||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
.menu-divider { height: 1px; background: var(--border); margin: 5px 4px; }
|
.menu-divider { height: 1px; background: var(--border); margin: 5px 4px; }
|
||||||
|
|
||||||
|
/* the Layouts menu's grid-density row: Grid Size − 12 + */
|
||||||
|
.menu-stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.menu-stepper-label { flex: 1; }
|
||||||
|
.menu-stepper-btn {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.menu-stepper-btn:hover { border-color: var(--muted); }
|
||||||
|
.menu-stepper-btn[disabled] { opacity: 0.35; cursor: default; }
|
||||||
|
.menu-stepper-btn svg { width: 12px; height: 12px; display: block; }
|
||||||
|
.menu-stepper-value {
|
||||||
|
min-width: 28px;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
.menu-title {
|
.menu-title {
|
||||||
padding: 6px 10px 3px;
|
padding: 6px 10px 3px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|||||||
@@ -1619,6 +1619,7 @@ function previousLayoutPath(name) {
|
|||||||
so an old file does not haunt a new client. */
|
so an old file does not haunt a new client. */
|
||||||
|
|
||||||
const LAYOUT_VERSION = 2;
|
const LAYOUT_VERSION = 2;
|
||||||
|
const GRID_STEPS = [8, 10, 12, 16, 20, 24]; // the shell's grid densities
|
||||||
const MAX_LAYOUT_TILES = 100;
|
const MAX_LAYOUT_TILES = 100;
|
||||||
const KNOWN_TILE_FIELDS = new Set(['id', 'module', 'x', 'y', 'w', 'h', 'settings', 'variant', 'title', 'headPinned', 'minSize']);
|
const KNOWN_TILE_FIELDS = new Set(['id', 'module', 'x', 'y', 'w', 'h', 'settings', 'variant', 'title', 'headPinned', 'minSize']);
|
||||||
const RETIRED_TILE_FIELDS = new Set(['headHidden']);
|
const RETIRED_TILE_FIELDS = new Set(['headHidden']);
|
||||||
@@ -1644,6 +1645,9 @@ function sanitizeLayout(raw) {
|
|||||||
// Whether tile title bars auto-hide is part of how a dashboard is
|
// Whether tile title bars auto-hide is part of how a dashboard is
|
||||||
// arranged, so it travels with the layout like the tiles themselves.
|
// arranged, so it travels with the layout like the tiles themselves.
|
||||||
autoHideHeads: Boolean(raw?.autoHideHeads),
|
autoHideHeads: Boolean(raw?.autoHideHeads),
|
||||||
|
// The grid density the layout was arranged on, when it says and it is one
|
||||||
|
// the shell knows; a browser loading it cuts its board the same way.
|
||||||
|
...(GRID_STEPS.includes(Number(raw?.gridCols)) ? { gridCols: Number(raw.gridCols) } : {}),
|
||||||
tiles: tiles.map((t) => {
|
tiles: tiles.map((t) => {
|
||||||
const tile = {
|
const tile = {
|
||||||
id: String(t?.id || ''),
|
id: String(t?.id || ''),
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ async function waitFor(url, ms = 15000) {
|
|||||||
headHidden: true,
|
headHidden: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
let r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { autoHideHeads: true, tiles: [tile] } }) });
|
let r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { autoHideHeads: true, gridCols: 16, tiles: [tile] } }) });
|
||||||
assert.equal(r.status, 200, 'save: ' + JSON.stringify(r.body));
|
assert.equal(r.status, 200, 'save: ' + JSON.stringify(r.body));
|
||||||
assert.equal(r.body.replaced, false, 'a first save replaces nothing');
|
assert.equal(r.body.replaced, false, 'a first save replaces nothing');
|
||||||
|
|
||||||
@@ -97,6 +97,7 @@ async function waitFor(url, ms = 15000) {
|
|||||||
const got = r.body.layout;
|
const got = r.body.layout;
|
||||||
assert.equal(got.v, 2, 'the file carries a schema version');
|
assert.equal(got.v, 2, 'the file carries a schema version');
|
||||||
assert.equal(got.autoHideHeads, true);
|
assert.equal(got.autoHideHeads, true);
|
||||||
|
assert.equal(got.gridCols, 16, 'grid density travels with the layout');
|
||||||
const t = got.tiles[0];
|
const t = got.tiles[0];
|
||||||
for (const k of ['id', 'module', 'x', 'y', 'w', 'h', 'variant', 'title', 'headPinned']) assert.deepEqual(t[k], tile[k], k);
|
for (const k of ['id', 'module', 'x', 'y', 'w', 'h', 'variant', 'title', 'headPinned']) assert.deepEqual(t[k], tile[k], k);
|
||||||
assert.deepEqual(t.settings, tile.settings, 'settings');
|
assert.deepEqual(t.settings, tile.settings, 'settings');
|
||||||
@@ -107,7 +108,7 @@ async function waitFor(url, ms = 15000) {
|
|||||||
assert.equal('headHidden' in t, false, 'a retired field is dropped');
|
assert.equal('headHidden' in t, false, 'a retired field is dropped');
|
||||||
|
|
||||||
// Saving over the name keeps the copy it replaced.
|
// Saving over the name keeps the copy it replaced.
|
||||||
r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { tiles: [{ ...tile, title: 'Service v2' }] } }) });
|
r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { gridCols: 13, tiles: [{ ...tile, title: 'Service v2' }] } }) });
|
||||||
assert.equal(r.status, 200);
|
assert.equal(r.status, 200);
|
||||||
assert.equal(r.body.replaced, true, 'the second save reports a replacement');
|
assert.equal(r.body.replaced, true, 'the second save reports a replacement');
|
||||||
assert.ok(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), 'the replaced copy is kept');
|
assert.ok(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), 'the replaced copy is kept');
|
||||||
@@ -125,6 +126,7 @@ async function waitFor(url, ms = 15000) {
|
|||||||
assert.equal(r.status, 200);
|
assert.equal(r.status, 200);
|
||||||
r = await api('/api/layouts/Booth%20A');
|
r = await api('/api/layouts/Booth%20A');
|
||||||
assert.equal(r.body.layout.tiles[0].title, 'Service v2', 'restoring again goes forward again');
|
assert.equal(r.body.layout.tiles[0].title, 'Service v2', 'restoring again goes forward again');
|
||||||
|
assert.equal('gridCols' in r.body.layout, false, 'a density the shell does not offer is dropped, not stored');
|
||||||
r = await api('/api/layouts/Never%20Saved/restore', { method: 'POST', body: '{}' });
|
r = await api('/api/layouts/Never%20Saved/restore', { method: 'POST', body: '{}' });
|
||||||
assert.equal(r.status, 404, 'nothing to restore is said, not faked');
|
assert.equal(r.status, 404, 'nothing to restore is said, not faked');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user