Files
controls-web/assets/js/tag-controls.js
2026-02-17 12:44:37 -06:00

921 lines
26 KiB
JavaScript

(() => {
const formatValue = (value) => {
if (value === null || value === undefined || Number.isNaN(Number(value))) {
return '—';
}
const numeric = Number(value);
const absValue = Math.abs(numeric);
let maximumFractionDigits = 0;
if (absValue !== Math.trunc(absValue)) {
maximumFractionDigits = absValue < 1 ? 3 : absValue < 10 ? 2 : 1;
}
return numeric.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits
});
};
document.addEventListener('DOMContentLoaded', () => {
const app = document.querySelector('[data-control-app]');
if (!app) {
return;
}
const select = app.querySelector('[data-control-select]');
const typeFilter = app.querySelector('[data-control-type-filter]');
const valueDisplay = app.querySelector('[data-current-value]');
const timestampDisplay = app.querySelector('[data-current-timestamp]');
const valueLabel = app.querySelector('[data-value-label]');
const pendingIndicator = app.querySelector('[data-pending-indicator]');
const numericContainer = app.querySelector('[data-numeric-controls]');
const booleanContainer = app.querySelector('[data-boolean-controls]');
const stepInput = app.querySelector('[data-step-input]');
const setContainer = app.querySelector('[data-set-form]');
const setInput = app.querySelector('[data-set-input]');
const applyButton = app.querySelector('[data-action="apply"]');
const decreaseBtn = app.querySelector('[data-action="decrease"]');
const increaseBtn = app.querySelector('[data-action="increase"]');
const booleanOffBtn = app.querySelector('[data-boolean="off"]');
const booleanOnBtn = app.querySelector('[data-boolean="on"]');
const refreshBtn = app.querySelector('[data-action="refresh"]');
const status = app.querySelector('[data-status]');
const defaultNumericSettings = {
stepValue: stepInput ? Number(stepInput.value) || 1 : 1,
stepAttr: stepInput ? (stepInput.getAttribute('step') || '0.1') : '0.1',
stepMin: stepInput ? (stepInput.getAttribute('min') || '') : '',
setStepAttr: setInput ? (setInput.getAttribute('step') || '0.1') : '0.1',
placeholder: setInput ? (setInput.getAttribute('placeholder') || '') : ''
};
const getUrl = app.dataset.getUrl;
const updateUrl = app.dataset.updateUrl;
const csrfToken = app.dataset.csrf;
if (!select || !getUrl || !updateUrl) {
return;
}
const placeholderOption = select.options.length > 0 ? select.options[0] : null;
const placeholderDefaultText = placeholderOption ? placeholderOption.textContent : '';
const state = {
busy: false,
selectedId: select ? (select.value || null) : null,
statusTimeout: null,
selectedMode: 'numeric',
currentValue: null,
stagedValue: null,
numericConfig: null,
datasetUnits: null,
units: null,
description: null
};
const parseDatasetNumber = (value) => {
if (value === undefined || value === null || value === '') {
return null;
}
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
};
const extractNumericConfig = (option) => {
if (!option) {
return null;
}
const step = parseDatasetNumber(option.dataset.stepSize);
const min = parseDatasetNumber(option.dataset.minValue);
const max = parseDatasetNumber(option.dataset.maxValue);
const rawPrecision = option.dataset.precision;
const precisionNumber =
rawPrecision === undefined || rawPrecision === null || rawPrecision === ''
? null
: Number(rawPrecision);
const precision =
Number.isFinite(precisionNumber) && precisionNumber >= 0
? Math.round(precisionNumber)
: null;
const units = option.dataset.units && option.dataset.units !== '' ? option.dataset.units : null;
const description =
option.dataset.description && option.dataset.description !== ''
? option.dataset.description
: null;
return {
step: typeof step === 'number' && step > 0 ? step : null,
min: typeof min === 'number' ? min : null,
max: typeof max === 'number' ? max : null,
precision,
units,
description
};
};
const delay = (ms) => new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
const applyNumericConstraints = (value) => {
if (typeof value !== 'number' || Number.isNaN(value)) {
return value;
}
const config = state.numericConfig;
if (!config) {
return value;
}
let result = value;
if (typeof config.min === 'number' && result < config.min) {
result = config.min;
}
if (typeof config.max === 'number' && result > config.max) {
result = config.max;
}
if (typeof config.precision === 'number' && config.precision >= 0) {
result = Number(result.toFixed(config.precision));
}
return result;
};
const getUnitsLabel = () => state.units || state.datasetUnits || null;
const applyNumericConfig = (option) => {
const config = extractNumericConfig(option);
state.numericConfig = config;
state.datasetUnits = config ? config.units : null;
state.description = config ? config.description : null;
state.units = null;
if (stepInput) {
const stepValue =
config && typeof config.step === 'number' ? config.step : defaultNumericSettings.stepValue;
stepInput.value = stepValue;
stepInput.step =
config && typeof config.step === 'number' ? config.step : defaultNumericSettings.stepAttr;
if (config && typeof config.min === 'number') {
stepInput.min = config.min;
} else if (defaultNumericSettings.stepMin !== '') {
stepInput.min = defaultNumericSettings.stepMin;
} else {
stepInput.removeAttribute('min');
}
}
if (setInput) {
if (config && typeof config.min === 'number') {
setInput.min = config.min;
} else {
setInput.removeAttribute('min');
}
if (config && typeof config.max === 'number') {
setInput.max = config.max;
} else {
setInput.removeAttribute('max');
}
if (config && typeof config.step === 'number') {
setInput.step = config.step;
} else {
setInput.step = defaultNumericSettings.setStepAttr;
}
if (config && config.units) {
setInput.placeholder = config.units;
} else {
setInput.placeholder = defaultNumericSettings.placeholder;
}
}
};
const getActionableElements = () => [
stepInput,
setInput,
applyButton,
decreaseBtn,
increaseBtn,
refreshBtn,
booleanOffBtn,
booleanOnBtn
].filter(Boolean);
const resolveMode = (hint) => (hint === 'boolean' ? 'boolean' : 'numeric');
const isBooleanMode = () => state.selectedMode === 'boolean';
const valuesEqual = (left, right) => {
if (left === null || left === undefined) {
return right === null || right === undefined;
}
if (right === null || right === undefined) {
return false;
}
const leftNumber = Number(left);
const rightNumber = Number(right);
if (Number.isNaN(leftNumber) || Number.isNaN(rightNumber)) {
return left === right;
}
return Math.abs(leftNumber - rightNumber) < 1e-9;
};
const parseNumericValue = (value) => {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
};
const formatBooleanDisplay = (value) => {
if (value === null || value === undefined) {
return '—';
}
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return String(value);
}
return numeric >= 0.5 ? 'ON' : 'OFF';
};
const updateBooleanButtons = (value) => {
if (!booleanOnBtn || !booleanOffBtn) {
return;
}
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
booleanOnBtn.classList.remove('button--toggle-active');
booleanOffBtn.classList.remove('button--toggle-active');
booleanOnBtn.setAttribute('aria-pressed', 'false');
booleanOffBtn.setAttribute('aria-pressed', 'false');
return;
}
const isOn = numeric >= 0.5;
booleanOnBtn.classList.toggle('button--toggle-active', isOn);
booleanOffBtn.classList.toggle('button--toggle-active', !isOn);
booleanOnBtn.setAttribute('aria-pressed', isOn ? 'true' : 'false');
booleanOffBtn.setAttribute('aria-pressed', !isOn ? 'true' : 'false');
};
const getVisualValue = () => {
if (isBooleanMode()) {
return state.currentValue;
}
if (Number.isFinite(state.stagedValue)) {
return state.stagedValue;
}
return state.currentValue;
};
const updatePendingIndicator = () => {
if (!pendingIndicator) {
return;
}
const pending = Boolean(
state.selectedId &&
!isBooleanMode() &&
Number.isFinite(state.stagedValue) &&
!valuesEqual(state.stagedValue, state.currentValue)
);
pendingIndicator.hidden = !pending;
};
const refreshValueDisplay = () => {
if (valueDisplay) {
const visualValue = getVisualValue();
valueDisplay.textContent = isBooleanMode()
? formatBooleanDisplay(visualValue)
: formatValue(visualValue);
}
updatePendingIndicator();
};
const isApplyReady = () => Boolean(
state.selectedId &&
!isBooleanMode() &&
Number.isFinite(state.stagedValue) &&
!valuesEqual(state.stagedValue, state.currentValue)
);
const updateApplyAvailability = () => {
if (!applyButton) {
return;
}
applyButton.disabled =
state.busy ||
!isApplyReady();
};
const setMode = (modeHint) => {
const mode = resolveMode(modeHint);
state.selectedMode = mode;
if (numericContainer) {
numericContainer.hidden = mode !== 'numeric';
}
if (booleanContainer) {
booleanContainer.hidden = mode !== 'boolean';
}
if (valueLabel) {
const unitsLabel = getUnitsLabel();
valueLabel.textContent = mode === 'boolean'
? 'Current state'
: `Current value${unitsLabel ? ` (${unitsLabel})` : ''}`;
}
if (setInput) {
if (mode === 'numeric') {
const unitsLabel = getUnitsLabel();
setInput.placeholder = unitsLabel || defaultNumericSettings.placeholder;
} else {
setInput.placeholder = '';
}
}
if (!isBooleanMode() && setInput && !Number.isFinite(state.stagedValue) && Number.isFinite(state.currentValue)) {
const constrainedValue = applyNumericConstraints(state.currentValue);
setInput.value = Number.isFinite(constrainedValue) ? constrainedValue : state.currentValue;
}
if (isBooleanMode()) {
updateBooleanButtons(state.currentValue);
}
refreshValueDisplay();
updateApplyAvailability();
};
const getSelectedOption = () => {
if (!state.selectedId) {
return null;
}
return Array.from(select.options).find((option) => option.value === state.selectedId) || null;
};
const updateModeFromSelection = () => {
const option = getSelectedOption();
applyNumericConfig(option);
const modeHint = option ? option.dataset.controlMode : 'numeric';
setMode(modeHint);
};
const applyTypeFilter = () => {
if (!select) {
return;
}
const filterValue = typeFilter ? typeFilter.value : '';
let visibleOptionExists = false;
Array.from(select.options).forEach((option, index) => {
if (index === 0) {
option.hidden = false;
option.disabled = false;
return;
}
const optionType = option.dataset.typeKey || '';
const isVisible = !filterValue || optionType === filterValue;
option.hidden = !isVisible;
option.disabled = !isVisible;
if (isVisible) {
visibleOptionExists = true;
}
});
if (select.value) {
const selected = select.options[select.selectedIndex];
if (!selected || selected.hidden || selected.disabled) {
select.value = '';
state.selectedId = null;
clearDisplay();
}
}
if (typeFilter) {
if (visibleOptionExists) {
typeFilter.removeAttribute('data-filter-empty');
} else {
typeFilter.setAttribute('data-filter-empty', 'true');
}
}
if (placeholderOption) {
placeholderOption.textContent = visibleOptionExists ? placeholderDefaultText : 'No tags for this type';
}
updateModeFromSelection();
updateControlAvailability();
};
const setStatus = (message, type = 'info') => {
if (!status) {
return;
}
if (state.statusTimeout) {
window.clearTimeout(state.statusTimeout);
state.statusTimeout = null;
}
if (!message) {
status.hidden = true;
status.textContent = '';
status.removeAttribute('data-status-type');
return;
}
status.hidden = false;
status.textContent = message;
status.dataset.statusType = type;
const timeout = type === 'error' ? 6000 : 2400;
state.statusTimeout = window.setTimeout(() => {
status.hidden = true;
status.textContent = '';
status.removeAttribute('data-status-type');
}, timeout);
};
const setBusy = (busy) => {
state.busy = busy;
select.disabled = busy;
updateControlAvailability();
};
const updateControlAvailability = () => {
const hasSelection = Boolean(state.selectedId);
const numericDisabled = state.busy || !hasSelection || isBooleanMode();
const booleanDisabled = state.busy || !hasSelection || !isBooleanMode();
if (stepInput) {
stepInput.disabled = numericDisabled;
}
if (setInput) {
setInput.disabled = numericDisabled;
}
if (decreaseBtn) {
decreaseBtn.disabled = numericDisabled;
}
if (increaseBtn) {
increaseBtn.disabled = numericDisabled;
}
if (booleanOffBtn) {
booleanOffBtn.disabled = booleanDisabled;
}
if (booleanOnBtn) {
booleanOnBtn.disabled = booleanDisabled;
}
if (refreshBtn) {
refreshBtn.disabled = state.busy || !hasSelection;
}
if (typeFilter) {
typeFilter.disabled = state.busy;
}
updateApplyAvailability();
};
const clearDisplay = () => {
state.currentValue = null;
state.stagedValue = null;
applyNumericConfig(null);
if (timestampDisplay) {
timestampDisplay.textContent = '';
}
if (setInput) {
setInput.value = '';
}
updateBooleanButtons(null);
setMode('numeric');
if (valueDisplay) {
valueDisplay.textContent = '—';
}
if (pendingIndicator) {
pendingIndicator.hidden = true;
}
};
const applyData = (data) => {
if (!data) {
clearDisplay();
return;
}
state.units = data.units || null;
if (!state.units && state.datasetUnits) {
state.units = state.datasetUnits;
}
if (data.description) {
state.description = data.description;
}
const resolvedMode = resolveMode(data.control_mode);
setMode(resolvedMode);
if (timestampDisplay) {
timestampDisplay.textContent = data.displayTimestamp || data.timestamp || '';
}
if (resolvedMode === 'boolean') {
const booleanValue = Number(data.value);
state.currentValue = Number.isFinite(booleanValue) ? booleanValue : null;
state.stagedValue = null;
updateBooleanButtons(state.currentValue);
refreshValueDisplay();
updateApplyAvailability();
} else {
const numericValue = parseNumericValue(data.value);
state.currentValue = numericValue;
stageNumericValue(numericValue);
}
updateControlAvailability();
};
const stageNumericValue = (value, { updateInput = true } = {}) => {
if (isBooleanMode()) {
return;
}
if (value === null || value === undefined || value === '') {
state.stagedValue = null;
if (setInput && updateInput) {
setInput.value = '';
}
refreshValueDisplay();
updateApplyAvailability();
return;
}
let numericValue = parseNumericValue(value);
if (!Number.isFinite(numericValue)) {
state.stagedValue = null;
if (setInput && updateInput) {
setInput.value = '';
}
refreshValueDisplay();
updateApplyAvailability();
return;
}
numericValue = applyNumericConstraints(numericValue);
state.stagedValue = numericValue;
if (setInput && updateInput) {
setInput.value = Number.isFinite(numericValue) ? numericValue : '';
}
refreshValueDisplay();
updateApplyAvailability();
};
const adjustStagedValue = (direction) => {
if (!state.selectedId || isBooleanMode() || !stepInput) {
return;
}
const step = parseNumericValue(stepInput.value);
if (step === null || step <= 0) {
setStatus('Step size must be greater than zero.', 'error');
return;
}
const base = Number.isFinite(state.stagedValue)
? state.stagedValue
: (Number.isFinite(state.currentValue) ? state.currentValue : 0);
const newValue = base + direction * Math.abs(step);
const constrainedValue = applyNumericConstraints(newValue);
stageNumericValue(constrainedValue);
if (!state.busy) {
setStatus('Adjustments pending. Press Apply to commit.', 'info');
}
updateControlAvailability();
};
const buildUrlWithId = (baseUrl, id) => {
const url = new URL(baseUrl, window.location.href);
url.searchParams.set('id', id);
return url.toString();
};
const fetchJson = async (input, init = {}) => {
const response = await fetch(input, init);
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload.success) {
const message = payload.message || `Request failed (HTTP ${response.status})`;
if (payload && payload.details) {
console.error('Request failed with details:', payload.details);
}
throw new Error(payload && payload.details ? `${message}${payload.details}` : message);
}
return payload;
};
const loadTagValue = async (silent = false) => {
if (!state.selectedId) {
clearDisplay();
return;
}
const maxAttempts = 2;
const retryDelayMs = 600;
setBusy(true);
try {
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
if (!silent) {
const message = attempt === 1
? 'Loading current value&hellip;'
: 'Retrying load after a transient error&hellip;';
const tone = attempt === 1 ? 'info' : 'warning';
setStatus(message, tone);
}
try {
const payload = await fetchJson(
buildUrlWithId(getUrl, state.selectedId),
{
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
cache: 'no-cache'
}
);
applyData(payload.data);
if (!silent) {
setStatus('Latest value loaded.', 'success');
}
return;
} catch (error) {
console.warn(`Attempt ${attempt} to load tag value failed.`, error);
if (attempt < maxAttempts) {
await delay(retryDelayMs);
continue;
}
console.error(error);
throw error;
}
}
} catch (error) {
const message = error && error.message ? error.message : 'Failed to read tag value.';
setStatus(message, 'error');
clearDisplay();
} finally {
setBusy(false);
}
};
const sendUpdate = async (body, successMessage = 'Value updated successfully.') => {
if (!state.selectedId) {
return null;
}
try {
setBusy(true);
setStatus('Applying update&hellip;', 'info');
const payload = await fetchJson(updateUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': csrfToken || ''
},
body: JSON.stringify({
id: state.selectedId,
...body
})
});
applyData(payload.data);
let statusTone = 'success';
let statusMessage = successMessage;
if (payload.log) {
const { success, driver, error, path } = payload.log;
if (!success) {
statusTone = 'warning';
statusMessage = 'Value updated, but audit log failed. Check with an administrator.';
console.error('Control write audit log failed to persist.', payload.log);
} else if (driver && driver !== 'database') {
statusTone = 'warning';
statusMessage = 'Value updated. Audit log stored in fallback location.';
console.warn('Control write audit log fallback engaged.', payload.log);
if (path) {
console.info('Fallback log path:', path);
}
} else {
console.debug('Control write audit logged successfully.');
}
if (error) {
console.debug('Audit log diagnostics:', error);
}
}
setStatus(statusMessage, statusTone);
return payload;
} catch (error) {
console.error(error);
setStatus(error.message, 'error');
return null;
} finally {
setBusy(false);
}
};
if (select) {
select.addEventListener('change', () => {
state.selectedId = select.value || null;
if (!state.selectedId) {
clearDisplay();
updateControlAvailability();
return;
}
updateModeFromSelection();
state.currentValue = null;
state.stagedValue = null;
if (setInput) {
setInput.value = '';
}
refreshValueDisplay();
updateControlAvailability();
loadTagValue(true);
});
}
if (typeFilter) {
typeFilter.addEventListener('change', () => {
applyTypeFilter();
const filterEmpty = typeFilter.getAttribute('data-filter-empty') === 'true';
if (!select.value) {
state.selectedId = null;
clearDisplay();
if (filterEmpty) {
setStatus('No tags available for this type.', 'warning');
} else {
setStatus('');
}
} else {
state.selectedId = select.value;
setStatus('');
}
updateControlAvailability();
});
}
if (decreaseBtn) {
decreaseBtn.addEventListener('click', () => {
adjustStagedValue(-1);
});
}
if (increaseBtn) {
increaseBtn.addEventListener('click', () => {
adjustStagedValue(1);
});
}
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
loadTagValue();
});
}
if (setInput) {
setInput.addEventListener('input', () => {
if (!state.selectedId || isBooleanMode()) {
return;
}
const raw = setInput.value;
if (raw === '') {
stageNumericValue(null, { updateInput: false });
updateControlAvailability();
return;
}
const numeric = parseNumericValue(raw);
if (numeric === null) {
state.stagedValue = null;
refreshValueDisplay();
updateApplyAvailability();
updateControlAvailability();
return;
}
stageNumericValue(numeric, { updateInput: false });
updateControlAvailability();
});
}
if (applyButton) {
applyButton.addEventListener('click', () => {
if (!isApplyReady()) {
setStatus('Provide a numeric value to apply.', 'error');
return;
}
const target = state.stagedValue;
sendUpdate({ value: target }, 'Value applied successfully.');
});
}
if (booleanOffBtn) {
booleanOffBtn.addEventListener('click', () => {
if (!state.selectedId || !isBooleanMode()) {
return;
}
if (valuesEqual(state.currentValue, 0)) {
setStatus('Switch is already Off.', 'info');
return;
}
sendUpdate({ value: 0 }, 'Switch turned off.');
});
}
if (booleanOnBtn) {
booleanOnBtn.addEventListener('click', () => {
if (!state.selectedId || !isBooleanMode()) {
return;
}
if (valuesEqual(state.currentValue, 1)) {
setStatus('Switch is already On.', 'info');
return;
}
sendUpdate({ value: 1 }, 'Switch turned on.');
});
}
applyTypeFilter();
updateControlAvailability();
if (state.selectedId) {
updateModeFromSelection();
loadTagValue(true);
}
});
})();