CI / Tests (PHP 8.1) (pull_request) Successful in 56s
CI / Tests (PHP 8.2) (pull_request) Successful in 46s
CI / No Debug Code (pull_request) Successful in 2s
CI / Coding Standards (pull_request) Successful in 3m3s
CI / PHPStan (pull_request) Successful in 2m51s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m50s
CI / Build Plugin Zip (pull_request) Skipped
Adding availability for 5:30-6:00 PM with the lesson length left on its 60-minute default saved nothing and said nothing. A window is stored as consecutive lesson-length slots, so one that fits no lesson splits into none: splitByDuration() returned [], createFromWindow() inserted nothing, and addSlot() discarded the result and re-rendered the page unchanged. The REST endpoint already rejected that window with a 400. The admin form checked the same rules separately, and its copy was both laxer and mute — an unreadable date, an end before the start, and a two-day window were bare `return`s, and it never checked offering ownership at all, so a crafted POST could tie a slot to another instructor's offering and inherit their price and payment routing. Both callers now go through WindowValidator, which returns the window or a WP_Error explaining the refusal. The endpoint returns that error as is; the page renders its message as a notice. handleFormAction returns a [notice, error] pair so deletes report themselves too, and a successful add says how many slots it created. Two failures could also go unnoticed underneath: wpdb::insert's result was ignored, and insert_id still holds the previous statement's id after a failed write, so a failure looked like a success — and could become the recurrence group of a weekly series, orphaning every later occurrence. weeks was unbounded server-side despite the form's max=52. availability-admin.js narrows the lesson-length choices to those that fit the window and blocks submission when none do, which is what makes the original mistake hard to repeat. It is a convenience: the server validates regardless. Closes #130
99 lines
3.4 KiB
JavaScript
99 lines
3.4 KiB
JavaScript
/**
|
||
* Availability form: keep the lesson-length choices honest.
|
||
*
|
||
* A window is stored as consecutive lesson-length slots, so one shorter than the
|
||
* chosen lesson length holds no slots at all and saves nothing. Picking 5:30–6:00
|
||
* PM while the length select sat on its default of 60 minutes used to do exactly
|
||
* that, silently. The server now rejects it with a message; this narrows the
|
||
* choices first so the mistake is hard to make.
|
||
*
|
||
* This is a convenience only — AvailabilityController and the REST endpoint both
|
||
* validate the same window server-side regardless of what happens here.
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
const form = document.getElementById('usc-add-availability');
|
||
if (!form) return;
|
||
|
||
const startEl = document.getElementById('start_dt');
|
||
const endEl = document.getElementById('end_dt');
|
||
const durationEl = document.getElementById('duration_minutes');
|
||
const warningEl = document.getElementById('usc-duration-warning');
|
||
const submitEl = form.querySelector('input[type="submit"], button[type="submit"]');
|
||
|
||
if (!startEl || !endEl || !durationEl) return;
|
||
|
||
/**
|
||
* Minutes between the two datetime-local inputs, or 0 when the pair is not a
|
||
* usable window yet — empty, unparseable, backwards, or spanning two days
|
||
* (which the server rejects on its own terms, with its own message).
|
||
*/
|
||
function windowMinutes() {
|
||
const start = new Date(startEl.value);
|
||
const end = new Date(endEl.value);
|
||
|
||
if (!startEl.value || !endEl.value || isNaN(start) || isNaN(end)) return 0;
|
||
if (end <= start) return 0;
|
||
if (startEl.value.slice(0, 10) !== endEl.value.slice(0, 10)) return 0;
|
||
|
||
return Math.round((end - start) / 60000);
|
||
}
|
||
|
||
function refresh() {
|
||
const minutes = windowMinutes();
|
||
const options = Array.from(durationEl.options);
|
||
|
||
// No usable window yet: leave every choice alone rather than fighting
|
||
// someone part-way through typing a date.
|
||
if (minutes === 0) {
|
||
options.forEach((option) => {
|
||
option.hidden = false;
|
||
option.disabled = false;
|
||
});
|
||
setBlocked(false);
|
||
return;
|
||
}
|
||
|
||
let fits = [];
|
||
|
||
options.forEach((option) => {
|
||
const tooLong = Number(option.value) > minutes;
|
||
|
||
option.hidden = tooLong;
|
||
option.disabled = tooLong;
|
||
|
||
if (!tooLong) fits.push(option);
|
||
});
|
||
|
||
if (fits.length === 0) {
|
||
// Nothing bookable fits, so the form cannot produce a single slot.
|
||
setBlocked(true);
|
||
return;
|
||
}
|
||
|
||
setBlocked(false);
|
||
|
||
// The selection may have just been hidden — fall back to the longest
|
||
// length that still fits, which is what the instructor most likely wants.
|
||
if (durationEl.selectedOptions[0] && durationEl.selectedOptions[0].disabled) {
|
||
durationEl.value = fits.reduce(
|
||
(longest, option) => (Number(option.value) > Number(longest.value) ? option : longest),
|
||
fits[0]
|
||
).value;
|
||
}
|
||
}
|
||
|
||
function setBlocked(blocked) {
|
||
if (warningEl) warningEl.hidden = !blocked;
|
||
if (submitEl) submitEl.disabled = blocked;
|
||
}
|
||
|
||
startEl.addEventListener('change', refresh);
|
||
startEl.addEventListener('input', refresh);
|
||
endEl.addEventListener('change', refresh);
|
||
endEl.addEventListener('input', refresh);
|
||
|
||
refresh();
|
||
}());
|