Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcae4bb318 |
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## 2.6.6 - 2026-08-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Timetable entry editing now validates end times locally, requires the end to be at least one minute after the start, and highlights the end field when the value is invalid.
|
||||||
|
|
||||||
## 2.6.5 - 2026-08-07
|
## 2.6.5 - 2026-08-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.6.5",
|
"version": "2.6.6",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -47,6 +47,101 @@
|
|||||||
return Number.isNaN(date.getTime()) ? null : date;
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseDateTimeLocalValue(value) {
|
||||||
|
var raw = String(value || '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var date = new Date(raw);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFieldValidity(input) {
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.setCustomValidity('');
|
||||||
|
input.classList.remove('is-invalid');
|
||||||
|
if (input.removeAttribute) {
|
||||||
|
input.removeAttribute('aria-invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFieldValidity(input, message) {
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.setCustomValidity(message);
|
||||||
|
input.classList.add('is-invalid');
|
||||||
|
if (input.setAttribute) {
|
||||||
|
input.setAttribute('aria-invalid', 'true');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEntryRow(row) {
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var startInput = row.querySelector('[name="entry_start_datetime[]"]');
|
||||||
|
var endInput = row.querySelector('[name="entry_end_datetime[]"]');
|
||||||
|
var startValue;
|
||||||
|
var endValue;
|
||||||
|
var startDate;
|
||||||
|
var endDate;
|
||||||
|
|
||||||
|
clearFieldValidity(endInput);
|
||||||
|
|
||||||
|
if (!startInput || !endInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startValue = String(startInput.value || '').trim();
|
||||||
|
endValue = String(endInput.value || '').trim();
|
||||||
|
|
||||||
|
if (!startValue || !endValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startDate = parseDateTimeLocalValue(startValue);
|
||||||
|
endDate = parseDateTimeLocalValue(endValue);
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate.getTime() < startDate.getTime() + 60000) {
|
||||||
|
setFieldValidity(endInput, 'End time must be at least 1 minute after the start time.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindRowValidation(row) {
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var startInput = row.querySelector('[name="entry_start_datetime[]"]');
|
||||||
|
var endInput = row.querySelector('[name="entry_end_datetime[]"]');
|
||||||
|
|
||||||
|
if (!startInput || !endInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleValidation() {
|
||||||
|
validateEntryRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
startInput.addEventListener('input', handleValidation);
|
||||||
|
startInput.addEventListener('change', handleValidation);
|
||||||
|
endInput.addEventListener('input', handleValidation);
|
||||||
|
endInput.addEventListener('change', handleValidation);
|
||||||
|
|
||||||
|
validateEntryRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
function toUtcDateTimeLocalValue(value) {
|
function toUtcDateTimeLocalValue(value) {
|
||||||
var localDate = new Date(String(value || '').trim());
|
var localDate = new Date(String(value || '').trim());
|
||||||
return Number.isNaN(localDate.getTime()) ? '' : localDate.toISOString();
|
return Number.isNaN(localDate.getTime()) ? '' : localDate.toISOString();
|
||||||
@@ -110,6 +205,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
bindRemove(row);
|
bindRemove(row);
|
||||||
|
bindRowValidation(row);
|
||||||
body.appendChild(fragment);
|
body.appendChild(fragment);
|
||||||
markDirty();
|
markDirty();
|
||||||
}
|
}
|
||||||
@@ -117,6 +213,7 @@
|
|||||||
body.querySelectorAll('[data-timetable-entry-row]').forEach(function (row) {
|
body.querySelectorAll('[data-timetable-entry-row]').forEach(function (row) {
|
||||||
bindRemove(row);
|
bindRemove(row);
|
||||||
syncRowValuesToLocal(row);
|
syncRowValuesToLocal(row);
|
||||||
|
bindRowValidation(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
form.addEventListener('formdata', function (event) {
|
form.addEventListener('formdata', function (event) {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="datetime-local" name="entry_end_datetime[]" class="form-control" value="{{endValue}}" />
|
<input type="datetime-local" name="entry_end_datetime[]" class="form-control" value="{{endValue}}" />
|
||||||
|
<div class="invalid-feedback">End time must be at least 1 minute after the start time.</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-timetable-entry>Remove</button>
|
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-timetable-entry>Remove</button>
|
||||||
@@ -86,6 +87,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="datetime-local" name="entry_end_datetime[]" class="form-control" value="" />
|
<input type="datetime-local" name="entry_end_datetime[]" class="form-control" value="" />
|
||||||
|
<div class="invalid-feedback">End time must be at least 1 minute after the start time.</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-timetable-entry>Remove</button>
|
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-timetable-entry>Remove</button>
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
|
||||||
|
function createClassList() {
|
||||||
|
const classes = new Set();
|
||||||
|
|
||||||
|
return {
|
||||||
|
add(name) {
|
||||||
|
classes.add(name);
|
||||||
|
},
|
||||||
|
remove(name) {
|
||||||
|
classes.delete(name);
|
||||||
|
},
|
||||||
|
contains(name) {
|
||||||
|
return classes.has(name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInput(name, value) {
|
||||||
|
const listeners = Object.create(null);
|
||||||
|
const attributes = Object.create(null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: name,
|
||||||
|
value: value || '',
|
||||||
|
dataset: {},
|
||||||
|
classList: createClassList(),
|
||||||
|
attributes: attributes,
|
||||||
|
validityMessage: '',
|
||||||
|
addEventListener(type, handler) {
|
||||||
|
if (!listeners[type]) {
|
||||||
|
listeners[type] = [];
|
||||||
|
}
|
||||||
|
listeners[type].push(handler);
|
||||||
|
},
|
||||||
|
dispatchEvent(event) {
|
||||||
|
const handlers = listeners[event.type] || [];
|
||||||
|
handlers.forEach(function (handler) {
|
||||||
|
handler.call(this, event);
|
||||||
|
}, this);
|
||||||
|
},
|
||||||
|
setCustomValidity(message) {
|
||||||
|
this.validityMessage = String(message || '');
|
||||||
|
},
|
||||||
|
setAttribute(name, value) {
|
||||||
|
attributes[name] = String(value);
|
||||||
|
},
|
||||||
|
removeAttribute(name) {
|
||||||
|
delete attributes[name];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRow(startValue, endValue) {
|
||||||
|
const startInput = createInput('entry_start_datetime[]', startValue);
|
||||||
|
const endInput = createInput('entry_end_datetime[]', endValue);
|
||||||
|
const titleInput = createInput('entry_title[]', 'Opening');
|
||||||
|
const descriptionInput = createInput('entry_short_description[]', '');
|
||||||
|
const idInput = createInput('entry_id[]', '1');
|
||||||
|
const removeButton = {
|
||||||
|
addEventListener() {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
startInput: startInput,
|
||||||
|
endInput: endInput,
|
||||||
|
querySelector(selector) {
|
||||||
|
if (selector === '[name="entry_start_datetime[]"]') {
|
||||||
|
return startInput;
|
||||||
|
}
|
||||||
|
if (selector === '[name="entry_end_datetime[]"]') {
|
||||||
|
return endInput;
|
||||||
|
}
|
||||||
|
if (selector === '[name="entry_title[]"]') {
|
||||||
|
return titleInput;
|
||||||
|
}
|
||||||
|
if (selector === '[name="entry_short_description[]"]') {
|
||||||
|
return descriptionInput;
|
||||||
|
}
|
||||||
|
if (selector === '[name="entry_id[]"]') {
|
||||||
|
return idInput;
|
||||||
|
}
|
||||||
|
if (selector === '[data-remove-timetable-entry]') {
|
||||||
|
return removeButton;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
remove() {
|
||||||
|
row.removed = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTimeLocalValue(date) {
|
||||||
|
function pad(value) {
|
||||||
|
return String(value).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
String(date.getFullYear()).padStart(4, '0'),
|
||||||
|
'-',
|
||||||
|
pad(date.getMonth() + 1),
|
||||||
|
'-',
|
||||||
|
pad(date.getDate()),
|
||||||
|
'T',
|
||||||
|
pad(date.getHours()),
|
||||||
|
':',
|
||||||
|
pad(date.getMinutes())
|
||||||
|
].join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('timetable group form highlights an end time that is too early', () => {
|
||||||
|
const row = createRow('2026-08-07T10:00', '2026-08-07T10:00');
|
||||||
|
const body = {
|
||||||
|
rows: [row],
|
||||||
|
querySelectorAll(selector) {
|
||||||
|
return selector === '[data-timetable-entry-row]' ? this.rows.slice() : [];
|
||||||
|
},
|
||||||
|
appendChild(node) {
|
||||||
|
this.rows.push(node);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const addButton = {
|
||||||
|
listeners: Object.create(null),
|
||||||
|
addEventListener(type, handler) {
|
||||||
|
this.listeners[type] = handler;
|
||||||
|
},
|
||||||
|
click() {
|
||||||
|
if (this.listeners.click) {
|
||||||
|
this.listeners.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const form = {
|
||||||
|
dataset: {},
|
||||||
|
addEventListener() {}
|
||||||
|
};
|
||||||
|
const templateRow = createRow('', '');
|
||||||
|
const template = {
|
||||||
|
content: {
|
||||||
|
cloneNode() {
|
||||||
|
return {
|
||||||
|
querySelector(selector) {
|
||||||
|
return selector === '[data-timetable-entry-row]' ? templateRow : null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sandbox = {
|
||||||
|
document: {
|
||||||
|
getElementById(id) {
|
||||||
|
if (id === 'timetable-group-form') {
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
if (id === 'timetable-entry-row-template') {
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
querySelector(selector) {
|
||||||
|
if (selector === '[data-timetable-entries-body]') {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
if (selector === '[data-add-timetable-entry]') {
|
||||||
|
return addButton;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
window: {},
|
||||||
|
Date: Date,
|
||||||
|
Number: Number,
|
||||||
|
String: String,
|
||||||
|
Array: Array,
|
||||||
|
Object: Object,
|
||||||
|
Math: Math,
|
||||||
|
JSON: JSON,
|
||||||
|
console: console,
|
||||||
|
module: { exports: {} },
|
||||||
|
exports: {}
|
||||||
|
};
|
||||||
|
sandbox.window = sandbox;
|
||||||
|
|
||||||
|
const scriptPath = path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'data-sources', 'timetable-group-form.js');
|
||||||
|
const script = fs.readFileSync(scriptPath, 'utf8');
|
||||||
|
vm.runInNewContext(script, sandbox, { filename: scriptPath });
|
||||||
|
|
||||||
|
assert.equal(row.endInput.validityMessage, 'End time must be at least 1 minute after the start time.');
|
||||||
|
assert.equal(row.endInput.classList.contains('is-invalid'), true);
|
||||||
|
assert.equal(row.endInput.attributes['aria-invalid'], 'true');
|
||||||
|
|
||||||
|
row.endInput.value = formatDateTimeLocalValue(new Date(new Date(row.startInput.value).getTime() + 60000));
|
||||||
|
row.endInput.dispatchEvent({ type: 'input' });
|
||||||
|
|
||||||
|
assert.equal(row.endInput.validityMessage, '');
|
||||||
|
assert.equal(row.endInput.classList.contains('is-invalid'), false);
|
||||||
|
assert.equal(Object.prototype.hasOwnProperty.call(row.endInput.attributes, 'aria-invalid'), false);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user