initial
This commit is contained in:
@@ -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));
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user