Refactored polls module into layered architecture with typed domain objects, isolated SSE handling, and comprehensive tests.
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 4s
CI / Tests (Python 3.12) (push) Successful in 2m54s
CI / Tests (Python 3.13) (push) Successful in 2m54s
CI / Tests (Python 3.14) (push) Successful in 2m52s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 4s
CI / Tests (Python 3.12) (push) Successful in 2m54s
CI / Tests (Python 3.13) (push) Successful in 2m54s
CI / Tests (Python 3.14) (push) Successful in 2m52s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 5s
This commit is contained in:
@@ -12,95 +12,205 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
(() => {
|
||||
"use strict";
|
||||
import { readJsonConfig } from "./shared/config.js";
|
||||
import {
|
||||
createTextElement,
|
||||
requireElement,
|
||||
setHidden,
|
||||
} from "./shared/dom.js";
|
||||
|
||||
const config = window.CREATE_CONFIG;
|
||||
if (!config) return;
|
||||
class CreatePollForm {
|
||||
constructor(root, config) {
|
||||
this.root = root;
|
||||
this.config = config;
|
||||
this.form = requireElement(root, "#poll-form", HTMLFormElement);
|
||||
this.container = requireElement(root, "#options-container", HTMLElement);
|
||||
this.addButton = requireElement(root, "#add-option", HTMLButtonElement);
|
||||
this.multiCheckbox = requireElement(
|
||||
root,
|
||||
"#allow_multiple",
|
||||
HTMLInputElement
|
||||
);
|
||||
this.multiOptions = requireElement(
|
||||
root,
|
||||
"#multi-select-options",
|
||||
HTMLElement
|
||||
);
|
||||
this.minSelectionsInput = requireElement(
|
||||
root,
|
||||
"#min_selections",
|
||||
HTMLInputElement
|
||||
);
|
||||
this.maxSelectionsInput = requireElement(
|
||||
root,
|
||||
"#max_selections",
|
||||
HTMLInputElement
|
||||
);
|
||||
}
|
||||
|
||||
const container = document.getElementById('options-container');
|
||||
const addBtn = document.getElementById('add-option');
|
||||
const multiCheck = document.getElementById('allow_multiple');
|
||||
const multiOpts = document.getElementById('multi-select-options');
|
||||
const form = document.getElementById('poll-form');
|
||||
const maxOptions = config.maxOptions;
|
||||
const maxOptionLength = config.maxOptionLength;
|
||||
const minSel = document.getElementById('min_selections');
|
||||
const maxSel = document.getElementById('max_selections');
|
||||
start() {
|
||||
this.ensureMinimumOptions();
|
||||
this.renumberOptions();
|
||||
this.setMultiSelectEnabled(this.multiCheckbox.checked);
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
const syncSelectionLimits = () => {
|
||||
const minVal = parseInt(minSel.value, 10);
|
||||
const maxVal = parseInt(maxSel.value, 10);
|
||||
maxSel.min = minVal >= 1 ? minVal : 1;
|
||||
minSel.max = maxVal >= 1 ? maxVal : minSel.max;
|
||||
};
|
||||
bindEvents() {
|
||||
this.addButton.addEventListener("click", () => this.addOption());
|
||||
this.container.addEventListener("click", (event) =>
|
||||
this.removeOption(event)
|
||||
);
|
||||
this.multiCheckbox.addEventListener("change", () =>
|
||||
this.setMultiSelectEnabled(this.multiCheckbox.checked)
|
||||
);
|
||||
this.minSelectionsInput.addEventListener("input", () =>
|
||||
this.updateSelectionConstraints()
|
||||
);
|
||||
this.maxSelectionsInput.addEventListener("input", () =>
|
||||
this.updateSelectionConstraints()
|
||||
);
|
||||
this.form.addEventListener("submit", () =>
|
||||
this.updateSelectionConstraints()
|
||||
);
|
||||
}
|
||||
|
||||
const updateSelectionDefaults = () => {
|
||||
const count = container.querySelectorAll('.option-row').length;
|
||||
minSel.max = count;
|
||||
maxSel.max = count;
|
||||
maxSel.placeholder = count;
|
||||
if (minSel.value && parseInt(minSel.value, 10) > count) {
|
||||
minSel.value = count;
|
||||
optionRows() {
|
||||
return Array.from(this.container.querySelectorAll(".option-row"));
|
||||
}
|
||||
|
||||
ensureMinimumOptions() {
|
||||
while (this.optionRows().length < this.config.minOptions) {
|
||||
this.container.appendChild(this.buildOptionRow());
|
||||
}
|
||||
if (maxSel.value && parseInt(maxSel.value, 10) > count) {
|
||||
maxSel.value = count;
|
||||
}
|
||||
|
||||
addOption() {
|
||||
if (this.optionRows().length >= this.config.maxOptions) {
|
||||
return;
|
||||
}
|
||||
syncSelectionLimits();
|
||||
};
|
||||
this.container.appendChild(this.buildOptionRow());
|
||||
this.renumberOptions();
|
||||
}
|
||||
|
||||
minSel.addEventListener('input', syncSelectionLimits);
|
||||
maxSel.addEventListener('input', syncSelectionLimits);
|
||||
|
||||
const renumber = () => {
|
||||
const rows = container.querySelectorAll('.option-row');
|
||||
for (const [i, row] of [...rows].entries()) {
|
||||
const num = i + 1;
|
||||
row.querySelector('.option-number').textContent = num;
|
||||
const input = row.querySelector('.option-input');
|
||||
input.name = `option_${num}`;
|
||||
input.placeholder = `Option ${num}`;
|
||||
removeOption(event) {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
addBtn.disabled = rows.length >= maxOptions;
|
||||
updateSelectionDefaults();
|
||||
};
|
||||
|
||||
addBtn.addEventListener('click', () => {
|
||||
const rows = container.querySelectorAll('.option-row');
|
||||
if (rows.length >= maxOptions) return;
|
||||
const num = rows.length + 1;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'input-group mb-2 option-row';
|
||||
div.innerHTML =
|
||||
`<span class="input-group-text option-number">${num}</span>` +
|
||||
`<input type="text" class="form-control option-input" name="option_${num}" required maxlength="${maxOptionLength}" placeholder="Option ${num}">` +
|
||||
'<button type="button" class="btn btn-outline-danger remove-option">Remove</button>';
|
||||
container.appendChild(div);
|
||||
renumber();
|
||||
});
|
||||
|
||||
container.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('remove-option')) {
|
||||
e.target.closest('.option-row').remove();
|
||||
renumber();
|
||||
const button = target.closest(".remove-option");
|
||||
if (button === null || !this.container.contains(button)) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
multiCheck.addEventListener('change', () => {
|
||||
if (multiCheck.checked) {
|
||||
updateSelectionDefaults();
|
||||
multiOpts.classList.remove('d-none');
|
||||
} else {
|
||||
multiOpts.classList.add('d-none');
|
||||
minSel.value = '';
|
||||
maxSel.value = '';
|
||||
const row = button.closest(".option-row");
|
||||
if (row === null || this.optionRows().length <= this.config.minOptions) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', () => {
|
||||
const lines = [...container.querySelectorAll('.option-input')]
|
||||
.map(input => input.value.trim())
|
||||
.filter(Boolean);
|
||||
document.getElementById('options-hidden').value = lines.join('\n');
|
||||
});
|
||||
})();
|
||||
row.remove();
|
||||
this.renumberOptions();
|
||||
}
|
||||
|
||||
buildOptionRow() {
|
||||
const row = document.createElement("div");
|
||||
row.className = "input-group mb-2 option-row";
|
||||
|
||||
const number = this.optionRows().length + 1;
|
||||
const numberLabel = createTextElement(
|
||||
"span",
|
||||
"input-group-text option-number",
|
||||
String(number)
|
||||
);
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.className = "form-control option-input";
|
||||
input.name = "options";
|
||||
input.required = true;
|
||||
input.maxLength = this.config.maxOptionLength;
|
||||
input.placeholder = `Option ${number}`;
|
||||
|
||||
row.append(numberLabel, input);
|
||||
|
||||
if (number > this.config.minOptions) {
|
||||
const removeButton = createTextElement(
|
||||
"button",
|
||||
"btn btn-outline-danger remove-option",
|
||||
"Remove"
|
||||
);
|
||||
removeButton.type = "button";
|
||||
row.appendChild(removeButton);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
renumberOptions() {
|
||||
const rows = this.optionRows();
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const number = index + 1;
|
||||
requireElement(row, ".option-number", HTMLElement).textContent =
|
||||
String(number);
|
||||
requireElement(row, ".option-input", HTMLInputElement).placeholder =
|
||||
`Option ${number}`;
|
||||
}
|
||||
|
||||
this.addButton.disabled = rows.length >= this.config.maxOptions;
|
||||
this.updateSelectionConstraints();
|
||||
}
|
||||
|
||||
setMultiSelectEnabled(enabled) {
|
||||
this.minSelectionsInput.disabled = !enabled;
|
||||
this.maxSelectionsInput.disabled = !enabled;
|
||||
setHidden(this.multiOptions, !enabled);
|
||||
|
||||
if (!enabled) {
|
||||
this.minSelectionsInput.value = "";
|
||||
this.maxSelectionsInput.value = "";
|
||||
this.minSelectionsInput.setCustomValidity("");
|
||||
this.maxSelectionsInput.setCustomValidity("");
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateSelectionConstraints();
|
||||
}
|
||||
|
||||
updateSelectionConstraints() {
|
||||
const optionCount = this.optionRows().length;
|
||||
const minValue = this.parseSelectionValue(this.minSelectionsInput);
|
||||
const maxValue = this.parseSelectionValue(this.maxSelectionsInput);
|
||||
|
||||
this.minSelectionsInput.max = String(optionCount);
|
||||
this.maxSelectionsInput.max = String(optionCount);
|
||||
this.maxSelectionsInput.placeholder = String(optionCount);
|
||||
this.maxSelectionsInput.min =
|
||||
Number.isInteger(minValue) && minValue >= 1 ? String(minValue) : "1";
|
||||
|
||||
this.minSelectionsInput.setCustomValidity("");
|
||||
this.maxSelectionsInput.setCustomValidity("");
|
||||
|
||||
if (
|
||||
!this.multiCheckbox.checked ||
|
||||
!Number.isInteger(minValue) ||
|
||||
!Number.isInteger(maxValue)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (minValue > maxValue) {
|
||||
this.maxSelectionsInput.setCustomValidity(
|
||||
"Maximum selections must be at least the minimum."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
parseSelectionValue(input) {
|
||||
if (input.value.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
return Number(input.value);
|
||||
}
|
||||
}
|
||||
|
||||
const config = readJsonConfig("create-config");
|
||||
new CreatePollForm(document, config).start();
|
||||
|
||||
@@ -12,174 +12,195 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
(() => {
|
||||
"use strict";
|
||||
import { readJsonConfig } from "./shared/config.js";
|
||||
import {
|
||||
disableControls,
|
||||
requireElement,
|
||||
showAlert,
|
||||
} from "./shared/dom.js";
|
||||
import {
|
||||
isKeepalivePayload,
|
||||
isPollEndedPayload,
|
||||
isTallyPayload,
|
||||
PollEventStream,
|
||||
} from "./shared/poll-events.js";
|
||||
|
||||
const config = window.POLL_CONFIG;
|
||||
const viewerInfo = window.VIEWER_INFO;
|
||||
if (!config || !viewerInfo) return;
|
||||
function normalizeSeconds(value) {
|
||||
return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
|
||||
}
|
||||
|
||||
const timerEl = document.getElementById("timer");
|
||||
let remaining = config.timeRemaining;
|
||||
function formatTime(seconds) {
|
||||
const safeSeconds = normalizeSeconds(seconds);
|
||||
const minutes = Math.floor(safeSeconds / 60);
|
||||
const remainingSeconds = safeSeconds % 60;
|
||||
return `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// --- Countdown timer ---
|
||||
class CountdownTimer {
|
||||
constructor(element, initialSeconds) {
|
||||
this.element = element;
|
||||
this.remaining = normalizeSeconds(initialSeconds);
|
||||
this.intervalId = null;
|
||||
}
|
||||
|
||||
const formatTime = (seconds) => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
};
|
||||
start() {
|
||||
this.render();
|
||||
this.ensureInterval();
|
||||
}
|
||||
|
||||
const updateTimer = () => {
|
||||
if (remaining <= 0) {
|
||||
timerEl.textContent = "0:00";
|
||||
stop() {
|
||||
if (this.intervalId === null) {
|
||||
return;
|
||||
}
|
||||
timerEl.textContent = formatTime(remaining);
|
||||
remaining--;
|
||||
};
|
||||
window.clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
|
||||
updateTimer();
|
||||
const timerInterval = setInterval(() => {
|
||||
updateTimer();
|
||||
if (remaining < 0) {
|
||||
clearInterval(timerInterval);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// --- Update vote bars ---
|
||||
|
||||
const updateBars = (counts) => {
|
||||
const total = counts.reduce((a, b) => a + b, 0);
|
||||
for (const bar of document.querySelectorAll(".option-bar")) {
|
||||
const idx = Number(bar.dataset.index);
|
||||
const fill = bar.querySelector(".bar-fill");
|
||||
const countSpan = bar.querySelector(".vote-count");
|
||||
const pct = total > 0 ? Math.round((counts[idx] / total) * 100) : 0;
|
||||
if (fill && counts[idx] !== undefined) {
|
||||
fill.style.width = `${pct}%`;
|
||||
}
|
||||
if (countSpan) {
|
||||
const countEl = countSpan.querySelector(".count");
|
||||
const pctEl = countSpan.querySelector(".pct");
|
||||
if (countEl) countEl.textContent = counts[idx];
|
||||
if (pctEl) pctEl.textContent = pct;
|
||||
countSpan.classList.remove("d-none");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Show vote counts immediately for non-hidden polls so the UI
|
||||
// doesn't look identical to a hidden poll before SSE delivers data.
|
||||
if (!config.hidden || viewerInfo.isModerator) {
|
||||
for (const el of document.querySelectorAll(".vote-count")) {
|
||||
el.classList.remove("d-none");
|
||||
setRemaining(seconds) {
|
||||
this.remaining = normalizeSeconds(seconds);
|
||||
this.render();
|
||||
if (this.remaining > 0) {
|
||||
this.ensureInterval();
|
||||
} else {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// --- SSE connection ---
|
||||
|
||||
const evtSource = new EventSource(viewerInfo.eventsUrl);
|
||||
|
||||
const parse = (e) => {
|
||||
try {
|
||||
return JSON.parse(e.data);
|
||||
} catch {
|
||||
return null;
|
||||
ensureInterval() {
|
||||
if (this.intervalId !== null || this.remaining <= 0) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
this.intervalId = window.setInterval(() => this.tick(), 1000);
|
||||
}
|
||||
|
||||
evtSource.addEventListener("error", () => {
|
||||
if (evtSource.readyState === EventSource.CLOSED) {
|
||||
clearInterval(timerInterval);
|
||||
const status = document.getElementById("poll-status");
|
||||
if (status) {
|
||||
status.innerHTML =
|
||||
'<div class="alert alert-danger mb-0" role="alert">' +
|
||||
"Connection lost. Results are not live. Refresh to reconnect.</div>";
|
||||
tick() {
|
||||
if (this.remaining <= 0) {
|
||||
this.stop();
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
|
||||
this.remaining -= 1;
|
||||
this.render();
|
||||
|
||||
if (this.remaining <= 0) {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
this.element.textContent = formatTime(this.remaining);
|
||||
}
|
||||
}
|
||||
|
||||
class ResultsBars {
|
||||
constructor(root) {
|
||||
this.rows = Array.from(root.querySelectorAll(".option-bar")).map(
|
||||
(row) => ({
|
||||
index: Number(row.dataset.index),
|
||||
fill: row.querySelector(".bar-fill"),
|
||||
countWrapper: row.querySelector(".vote-count"),
|
||||
count: row.querySelector(".count"),
|
||||
percent: row.querySelector(".pct"),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
showCounts() {
|
||||
for (const row of this.rows) {
|
||||
row.countWrapper?.classList.remove("d-none");
|
||||
}
|
||||
}
|
||||
|
||||
update(counts) {
|
||||
const total = counts.reduce((sum, count) => sum + count, 0);
|
||||
|
||||
for (const row of this.rows) {
|
||||
const count = counts[row.index] ?? 0;
|
||||
const percent = total > 0 ? Math.round((count / total) * 100) : 0;
|
||||
|
||||
if (row.fill !== null) {
|
||||
row.fill.style.width = `${percent}%`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
evtSource.addEventListener("keepalive", (e) => {
|
||||
const data = parse(e);
|
||||
if (data && typeof data.time_remaining === "number") {
|
||||
remaining = data.time_remaining;
|
||||
}
|
||||
});
|
||||
|
||||
evtSource.addEventListener("tally", (e) => {
|
||||
const data = parse(e);
|
||||
if (data) {
|
||||
updateBars(data.counts);
|
||||
}
|
||||
});
|
||||
|
||||
evtSource.addEventListener("poll_ended", (e) => {
|
||||
const data = parse(e);
|
||||
clearInterval(timerInterval);
|
||||
evtSource.close();
|
||||
if (data?.results_url) {
|
||||
window.location.href = data.results_url;
|
||||
}
|
||||
});
|
||||
|
||||
evtSource.addEventListener("poll_cancelled", () => {
|
||||
clearInterval(timerInterval);
|
||||
evtSource.close();
|
||||
const status = document.getElementById("poll-status");
|
||||
if (status) {
|
||||
status.innerHTML =
|
||||
'<div class="alert alert-danger mb-0" role="alert">' +
|
||||
"This poll has been cancelled.</div>";
|
||||
}
|
||||
const form = document.getElementById("vote-form");
|
||||
if (form) {
|
||||
for (const input of form.querySelectorAll(
|
||||
"input, button[type='submit']"
|
||||
)) {
|
||||
input.disabled = true;
|
||||
if (row.count !== null) {
|
||||
row.count.textContent = String(count);
|
||||
}
|
||||
if (row.percent !== null) {
|
||||
row.percent.textContent = String(percent);
|
||||
}
|
||||
row.countWrapper?.classList.remove("d-none");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Multi-select validation ---
|
||||
class VotePage {
|
||||
constructor(root, config) {
|
||||
this.root = root;
|
||||
this.config = config;
|
||||
this.status = requireElement(root, "#poll-status", HTMLElement);
|
||||
this.timer = new CountdownTimer(
|
||||
requireElement(root, "#timer", HTMLElement),
|
||||
config.timeRemaining
|
||||
);
|
||||
this.bars = new ResultsBars(root);
|
||||
this.stream = null;
|
||||
}
|
||||
|
||||
const voteForm = document.getElementById("vote-form");
|
||||
if (voteForm && config.allowMultiple) {
|
||||
voteForm.addEventListener("submit", (e) => {
|
||||
const checked = voteForm.querySelectorAll(
|
||||
'input[name="option"]:checked'
|
||||
);
|
||||
const count = checked.length;
|
||||
start() {
|
||||
this.timer.start();
|
||||
|
||||
if (count === 0) {
|
||||
e.preventDefault();
|
||||
alert("Please select at least one option.");
|
||||
return;
|
||||
}
|
||||
if (!this.config.hidden || this.config.isModerator) {
|
||||
this.bars.showCounts();
|
||||
}
|
||||
|
||||
if (
|
||||
config.minSelections !== null &&
|
||||
count < config.minSelections
|
||||
) {
|
||||
e.preventDefault();
|
||||
alert(
|
||||
`Please select at least ${config.minSelections} option(s).`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
config.maxSelections !== null &&
|
||||
count > config.maxSelections
|
||||
) {
|
||||
e.preventDefault();
|
||||
alert(
|
||||
`Please select at most ${config.maxSelections} option(s).`
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.stream = new PollEventStream(this.config.eventsUrl, {
|
||||
onClosed: () => this.handleConnectionClosed(),
|
||||
});
|
||||
this.stream.on(
|
||||
"keepalive",
|
||||
(data) => this.timer.setRemaining(data.time_remaining),
|
||||
isKeepalivePayload
|
||||
);
|
||||
this.stream.on(
|
||||
"tally",
|
||||
(data) => this.bars.update(data.counts),
|
||||
(data) => isTallyPayload(data, this.config.optionCount)
|
||||
);
|
||||
this.stream.on(
|
||||
"poll_ended",
|
||||
(data) => this.handlePollEnded(data),
|
||||
isPollEndedPayload
|
||||
);
|
||||
this.stream.on("poll_cancelled", () => this.handlePollCancelled());
|
||||
}
|
||||
})();
|
||||
|
||||
stop() {
|
||||
this.timer.stop();
|
||||
if (this.stream !== null) {
|
||||
this.stream.close();
|
||||
this.stream = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleConnectionClosed() {
|
||||
this.timer.stop();
|
||||
showAlert(
|
||||
this.status,
|
||||
"Connection lost. Results are not live. Refresh to reconnect."
|
||||
);
|
||||
}
|
||||
|
||||
handlePollEnded(data) {
|
||||
this.stop();
|
||||
window.location.href = data.results_url;
|
||||
}
|
||||
|
||||
handlePollCancelled() {
|
||||
this.stop();
|
||||
showAlert(this.status, "This poll has been cancelled.");
|
||||
disableControls(this.root);
|
||||
}
|
||||
}
|
||||
|
||||
const config = readJsonConfig("poll-config");
|
||||
new VotePage(document, config).start();
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright 2026 Logan Fick
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
export function readJsonConfig(id) {
|
||||
const script = document.getElementById(id);
|
||||
if (!(script instanceof HTMLScriptElement)) {
|
||||
throw new Error(`Missing JSON config script: #${id}`);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(script.textContent || "{}");
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid JSON config script: #${id}`, { cause: error });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright 2026 Logan Fick
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
export function requireElement(root, selector, elementType = Element) {
|
||||
const element = root.querySelector(selector);
|
||||
if (element === null) {
|
||||
throw new Error(`Missing required element: ${selector}`);
|
||||
}
|
||||
if (!(element instanceof elementType)) {
|
||||
throw new Error(`Element ${selector} is not a ${elementType.name}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
export function createTextElement(tagName, className, text) {
|
||||
const element = document.createElement(tagName);
|
||||
if (className) {
|
||||
element.className = className;
|
||||
}
|
||||
element.textContent = text;
|
||||
return element;
|
||||
}
|
||||
|
||||
export function setHidden(element, hidden) {
|
||||
element.classList.toggle("d-none", hidden);
|
||||
}
|
||||
|
||||
export function showAlert(container, message, variant = "danger") {
|
||||
const alert = document.createElement("div");
|
||||
alert.className = `alert alert-${variant} mb-0`;
|
||||
alert.setAttribute("role", "alert");
|
||||
alert.textContent = message;
|
||||
|
||||
container.replaceChildren(alert);
|
||||
}
|
||||
|
||||
export function disableControls(root) {
|
||||
for (const control of root.querySelectorAll("button, input, select, textarea")) {
|
||||
control.disabled = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2026 Logan Fick
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
function isRecord(value) {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function parseEventData(event) {
|
||||
try {
|
||||
return JSON.parse(event.data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isKeepalivePayload(value) {
|
||||
return isRecord(value) && Number.isFinite(value.time_remaining);
|
||||
}
|
||||
|
||||
export function isPollEndedPayload(value) {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.results_url === "string" &&
|
||||
value.results_url.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function isTallyPayload(value, optionCount) {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
Array.isArray(value.counts) &&
|
||||
value.counts.length === optionCount &&
|
||||
value.counts.every((count) => Number.isInteger(count) && count >= 0)
|
||||
);
|
||||
}
|
||||
|
||||
export class PollEventStream {
|
||||
constructor(url, { onClosed } = {}) {
|
||||
this.source = new EventSource(url);
|
||||
this.onClosed = onClosed;
|
||||
|
||||
this.source.addEventListener("error", () => {
|
||||
if (this.source.readyState === EventSource.CLOSED) {
|
||||
this.close();
|
||||
if (this.onClosed) {
|
||||
this.onClosed();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
on(eventName, handler, validator = () => true) {
|
||||
this.source.addEventListener(eventName, (event) => {
|
||||
const data = parseEventData(event);
|
||||
if (validator(data)) {
|
||||
handler(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.source.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user