This commit is contained in:
Martin Slachta
2026-06-11 19:03:29 +02:00
commit 0d829845c4
150 changed files with 38582 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
/*
* RsvAdminForm — shared submit handler for wp-admin forms.
*
* Serializes a <form> to JSON (via RsvFormEncoder), sends it to the form's
* `action` using the HTTP verb in `data-method`, always attaches the REST
* nonce, and reports the outcome through show_notice(). The only part that
* legitimately differs between forms — shaping the request body — is handled
* by the optional `transform(body, form)` hook.
*
* Usage:
* RsvAdminForm.bind(my_form, {
* transform: (body, form) => ({ ...body, block_size: parseInt(body.block_size) }),
* refresh: () => my_datagrid.refresh(),
* });
*/
const RsvAdminForm = {
// Attach a submit listener that sends the form as JSON.
bind(form, options = {}) {
if (!form) return;
form.addEventListener('submit', (event) => {
event.preventDefault();
RsvAdminForm.submit(form, options);
});
},
// Send the form now. Returns the fetch promise.
submit(form, { transform, refresh, onSuccess } = {}) {
let body = RsvFormEncoder.encode_form(form);
if (transform) body = transform(body, form);
// `form.method` always returns a string (default 'get'), so default to POST
// explicitly unless the view opted into a verb via data-method.
const method = (form.dataset.method || 'POST').toUpperCase();
return fetch(form.action, {
method,
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-WP-Nonce': ReservairServiceAPI.nonce,
},
body: JSON.stringify(body),
})
.then(async (response) => {
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || data?.message || 'Request failed');
return data;
})
.then((data) => {
show_notice(form, 'success', form.dataset.successMsg ?? 'Saved.');
if (refresh) refresh();
if (onSuccess) onSuccess(data);
})
.catch((error) => show_notice(form, 'error', error.message));
},
};
+41
View File
@@ -0,0 +1,41 @@
const RsvFormEncoder = {
// Serialize form element into a plain JS object supporting arrays.
// - Nested keys supported with dot notation: 'meta.email'
// - Array notation supported with trailing [] (e.g. 'times[]') or multiple inputs with same name
encode_form(form_element) {
const formData = new FormData(form_element);
const body = {};
for (const [rawKey, value] of formData.entries()) {
const isArrayNotation = rawKey.endsWith('[]');
const key = isArrayNotation ? rawKey.slice(0, -2) : rawKey;
const keys = key.split('.');
let current = body;
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i];
if (current[k] === undefined || typeof current[k] !== 'object') {
current[k] = {};
}
current = current[k];
}
const lastKey = keys[keys.length - 1];
if (isArrayNotation) {
if (!Array.isArray(current[lastKey])) current[lastKey] = [];
current[lastKey].push(value);
} else {
if (current[lastKey] === undefined) {
current[lastKey] = value;
} else if (Array.isArray(current[lastKey])) {
current[lastKey].push(value);
} else {
current[lastKey] = [current[lastKey], value];
}
}
}
return body;
}
}
+142
View File
@@ -0,0 +1,142 @@
const RsvFormSender = {
get_form_url(form_id) {
return ReservairServiceAPI.restUrl + '/form/' + form_id;
},
clear_feedback(form) {
form.querySelectorAll('.rsv-field-error').forEach(el => el.remove());
form.querySelectorAll('.rsv-invalid').forEach(el => el.classList.remove('rsv-invalid'));
form.querySelector('.rsv-error-summary')?.remove();
},
show_errors(form, errors) {
this.clear_feedback(form);
const ul = document.createElement('ul');
for (const err of errors) {
const li = document.createElement('li');
li.textContent = err.message;
ul.appendChild(li);
if (err.element) {
const field = form.querySelector(`[name="${err.element}"]`);
if (field) {
field.classList.add('rsv-invalid');
const msg = document.createElement('span');
msg.classList.add('rsv-field-error');
msg.textContent = err.message;
field.insertAdjacentElement('afterend', msg);
}
}
}
const summary = document.createElement('div');
summary.classList.add('rsv-error-summary');
summary.appendChild(ul);
form.prepend(summary);
},
show_success(form, _data) {
const s = ReservairStrings.form;
const wrapper = form.parentElement;
const existing = Array.from(wrapper.children);
const svgNS = 'http://www.w3.org/2000/svg';
const path = document.createElementNS(svgNS, 'path');
path.setAttribute('d', 'M6 14l6 6L22 8');
path.setAttribute('stroke', '#16a34a');
path.setAttribute('stroke-width', '2.5');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'round');
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('width', '28');
svg.setAttribute('height', '28');
svg.setAttribute('viewBox', '0 0 28 28');
svg.setAttribute('fill', 'none');
svg.appendChild(path);
const icon = document.createElement('div');
icon.className = 'success-icon';
icon.appendChild(svg);
const title = document.createElement('div');
title.className = 'success-title';
title.textContent = s.success_title;
const subtitle = document.createElement('p');
subtitle.className = 'success-msg';
subtitle.textContent = s.success_subtitle;
const reset_btn = document.createElement('button');
reset_btn.className = 'reset-btn';
reset_btn.textContent = s.new_reservation;
const state = document.createElement('div');
state.className = 'success-state';
state.append(icon, title, subtitle, reset_btn);
const msg = document.createElement('div');
msg.appendChild(state);
existing.forEach(child => child.style.display = 'none');
wrapper.appendChild(msg);
reset_btn.addEventListener('click', () => {
msg.remove();
form.reset();
this.clear_feedback(form);
existing.forEach(child => child.style.display = '');
});
},
set_loading(form, is_loading) {
const btn = form.querySelector('button[type="submit"], button:not([type])');
if (!btn) return;
btn.disabled = is_loading;
btn.classList.toggle('rsv-loading', is_loading);
},
encode_to_json(form) {
const fields = form.querySelectorAll('.rsv-form-field');
const body = {};
fields.forEach(field => {
const name = field.name ?? field.getAttribute('name');
try {
body[name] = JSON.parse(field.value);
} catch {
body[name] = field.value;
}
});
return body;
},
send_form(event) {
event.preventDefault();
const form = event.target;
this.clear_feedback(form);
this.set_loading(form, true);
const body = this.encode_to_json(form);
fetch(this.get_form_url(form.id), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
.then(async response => {
const data = await response.json().catch(() => null);
if (!response.ok) throw { status: response.status, body: data };
return data;
})
.then(data => {
this.show_success(form, data);
})
.catch(error => {
const errors = error?.body?.errors
?? [{ element: '', message: ReservairStrings.form.error_generic }];
this.show_errors(form, errors);
})
.finally(() => {
this.set_loading(form, false);
});
},
};
+229
View File
@@ -0,0 +1,229 @@
const RsvInlineFormBuilder = {
match_p(name, value) {
return (form) => String(form[name]) === String(value);
},
create(datasource) {
const fields = [];
const builder = {
datasource: datasource,
fieldset(legend, width = null) {
fields.push({ type: 'fieldset', legend, width });
return this;
},
input_text(name, label, value = '') {
fields.push({ type: 'text', name, label, value });
return this;
},
input_number(name, label, value = '') {
fields.push({ type: 'number', name, label, value });
return this;
},
input_date(name, label, value = '') {
fields.push({ type: 'date', name, label, value });
return this;
},
input_time(name, label, value = '') {
fields.push({ type: 'time', name, label, value });
return this;
},
input_textarea(name, label, value = '') {
fields.push({ type: 'textarea', name, label, value });
return this;
},
input_checkbox(name, label, checked = false) {
fields.push({ type: 'checkbox', name, label, checked });
return this;
},
input_hidden(name, value) {
fields.push({ type: 'hidden', name, value });
return this;
},
input_select(name, label, options, value = '') {
fields.push({ type: 'select', name, label, options, value });
return this;
},
show_if(predicate) {
const last = fields[fields.length - 1];
if (last) last.show_if = predicate;
return this;
},
build({ id, colspan = 1, save_label = 'Update', on_success, on_cancel } = {}) {
const td = document.createElement('td');
td.setAttribute('colspan', colspan);
const form = document.createElement('form');
const wrapper = document.createElement('div');
wrapper.classList.add('inline-edit-wrapper');
const hidden_inputs = [];
let current_fieldset = null;
let current_col = null;
const fieldsets = [];
const conditionals = [];
function ensure_fieldset() {
if (current_fieldset === null) {
current_fieldset = document.createElement('fieldset');
current_col = document.createElement('div');
current_col.classList.add('inline-edit-col');
fieldsets.push(current_fieldset);
}
}
for (const field of fields) {
if (field.type === 'hidden') {
hidden_inputs.push(field);
continue;
}
if (field.type === 'fieldset') {
if (current_fieldset !== null) {
current_fieldset.appendChild(current_col);
}
current_fieldset = document.createElement('fieldset');
if (field.width) current_fieldset.style.width = field.width;
const legend_el = document.createElement('legend');
legend_el.classList.add('inline-edit-legend');
legend_el.innerText = field.legend;
current_fieldset.appendChild(legend_el);
current_col = document.createElement('div');
current_col.classList.add('inline-edit-col');
fieldsets.push(current_fieldset);
continue;
}
ensure_fieldset();
const label_el = document.createElement('label');
const title = document.createElement('span');
title.classList.add('title');
title.innerText = field.label;
const wrap = document.createElement('span');
wrap.classList.add('input-text-wrap');
let input;
if (field.type === 'select') {
input = document.createElement('select');
input.name = field.name;
for (const opt of field.options) {
const option = document.createElement('option');
if (typeof opt === 'object' && opt !== null) {
option.value = opt.value;
option.textContent = opt.label;
option.selected = String(opt.value) === String(field.value);
} else {
option.value = opt;
option.textContent = opt;
option.selected = opt === field.value;
}
input.appendChild(option);
}
} else if (field.type === 'textarea') {
input = document.createElement('textarea');
input.name = field.name;
input.rows = 5;
input.style.width = '100%';
input.value = field.value ?? '';
} else {
input = document.createElement('input');
input.type = field.type;
input.name = field.name;
if (field.type === 'checkbox') {
input.checked = field.checked;
} else {
input.value = field.value ?? '';
}
}
wrap.appendChild(input);
label_el.replaceChildren(title, wrap);
current_col.appendChild(label_el);
if (field.show_if) conditionals.push({ label_el, predicate: field.show_if });
}
if (current_fieldset !== null) {
current_fieldset.appendChild(current_col);
}
const save_row = document.createElement('div');
save_row.classList.add('inline-edit-save', 'submit');
const error = document.createElement('div');
error.classList.add('notice', 'notice-error', 'notice-alt', 'inline', 'hidden');
const error_p = document.createElement('p');
error_p.classList.add('error');
error.appendChild(error_p);
const spinner = document.createElement('span');
spinner.classList.add('spinner');
const save_btn = document.createElement('button');
save_btn.type = 'button';
save_btn.classList.add('save', 'button', 'button-primary');
save_btn.innerText = save_label;
save_btn.onclick = () => {
const form_data = Object.fromEntries(new FormData(form));
for (const field of fields) {
if (field.type === 'checkbox') {
form_data[field.name] = field.name in form_data;
}
}
spinner.classList.add('is-active');
error.classList.add('hidden');
builder.datasource.put(id, form_data)
.then(() => { if (on_success) on_success(); })
.catch(err => {
error_p.innerText = err.message;
error.classList.remove('hidden');
})
.finally(() => spinner.classList.remove('is-active'));
};
const cancel_btn = document.createElement('button');
cancel_btn.type = 'button';
cancel_btn.classList.add('cancel', 'button');
cancel_btn.innerText = 'Cancel';
if (on_cancel) cancel_btn.onclick = on_cancel;
save_row.replaceChildren(save_btn, cancel_btn, spinner, error);
for (const h of hidden_inputs) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = h.name;
input.value = h.value ?? '';
form.appendChild(input);
}
wrapper.replaceChildren(...fieldsets, save_row);
form.appendChild(wrapper);
td.appendChild(form);
if (conditionals.length) {
const snapshot = () => {
const f = Object.fromEntries(new FormData(form));
for (const field of fields) if (field.type === 'checkbox') f[field.name] = field.name in f;
return f;
};
const sync_all = () => {
const f = snapshot();
for (const c of conditionals) c.label_el.classList.toggle('hidden', !c.predicate(f));
};
form.addEventListener('change', sync_all);
form.addEventListener('input', sync_all);
sync_all();
}
return td;
},
};
return builder;
},
};