initial
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
/*
|
||||
* Utilities for calling the API
|
||||
*/
|
||||
|
||||
function get_rest_url(resource) {
|
||||
return ReservairServiceAPI.restUrl + '/' + resource;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# JS Data Sources
|
||||
|
||||
The JavaScript *RSV* Client layer. The frontend uses `RsvDataSource` to work with the Reservair's REST API.
|
||||
|
||||
The `RsvDataSource` is an object for a specific resource & contains methods for working with it.
|
||||
@@ -0,0 +1,44 @@
|
||||
const RsvDataSource = {
|
||||
create_rsv_resource(base_url, { nonce } = {}) {
|
||||
function request(url, method, body) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (nonce) headers['X-WP-Nonce'] = nonce;
|
||||
else headers['X-WP-Nonce'] = ReservairServiceAPI.nonce;
|
||||
|
||||
return fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers,
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
}).then(r => {
|
||||
if (!r.ok) throw new Error(`${method} ${url} failed: ${r.status}`);
|
||||
return r.status === 204 ? null : r.json();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
base_url: base_url,
|
||||
get_page(skip = 0, limit = 20, params = {}) {
|
||||
const url = new URL(base_url);
|
||||
url.searchParams.set('skip', skip);
|
||||
url.searchParams.set('limit', limit);
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
url.searchParams.set(k, v);
|
||||
}
|
||||
return request(url, 'GET');
|
||||
},
|
||||
get(id) {
|
||||
return request(`${base_url}/${id}`, 'GET');
|
||||
},
|
||||
post(data) {
|
||||
return request(base_url, 'POST', data);
|
||||
},
|
||||
put(id, data) {
|
||||
return request(`${base_url}/${id}`, 'PUT', data);
|
||||
},
|
||||
delete(id) {
|
||||
return request(`${base_url}/${id}`, 'DELETE');
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
const RsvFormDefinitionResource = () =>
|
||||
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/form-definition');
|
||||
@@ -0,0 +1,19 @@
|
||||
const RsvReservationClient = {
|
||||
accept(reservation_id) {
|
||||
return this._post(reservation_id, 'accept');
|
||||
},
|
||||
|
||||
refuse(reservation_id) {
|
||||
return this._post(reservation_id, 'refuse');
|
||||
},
|
||||
|
||||
_post(reservation_id, action) {
|
||||
return fetch(`${ReservairServiceAPI.restUrl}/reservation/${reservation_id}/${action}`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-WP-Nonce': ReservairServiceAPI.nonce },
|
||||
}).then(r => {
|
||||
if (!r.ok) return r.json().then(e => { throw new Error(e.error || 'Request failed'); });
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
const RsvReservationResource = () =>
|
||||
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/reservation');
|
||||
@@ -0,0 +1,2 @@
|
||||
const RsvTimetableCapacityResource = (id) =>
|
||||
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + `/timetable/${id}/capacity`);
|
||||
@@ -0,0 +1,2 @@
|
||||
const RsvTimetableReservationResource = (id) =>
|
||||
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + `/timetable/${id}/reservation`);
|
||||
@@ -0,0 +1,2 @@
|
||||
const RsvTimetableResource = () =>
|
||||
RsvDataSource.create_rsv_resource(ReservairServiceAPI.restUrl + '/timetable');
|
||||
@@ -0,0 +1,3 @@
|
||||
# Elements
|
||||
|
||||
Some repeating components of the UI.
|
||||
@@ -0,0 +1,196 @@
|
||||
const RsvCalendarPicker = (() => {
|
||||
|
||||
function get_first_day_of_month(date) {
|
||||
const day = new Date(date.getFullYear(), date.getMonth(), 1).getDay();
|
||||
return day === 0 ? 6 : day - 1; // Mon=0 … Sun=6
|
||||
}
|
||||
|
||||
function is_same_day(a, b) {
|
||||
return a.getUTCFullYear() === b.getUTCFullYear()
|
||||
&& a.getUTCMonth() === b.getUTCMonth()
|
||||
&& a.getUTCDate() === b.getUTCDate();
|
||||
}
|
||||
|
||||
function is_same_month(a, b) {
|
||||
return a.getFullYear() === b.getFullYear()
|
||||
&& a.getMonth() === b.getMonth();
|
||||
}
|
||||
|
||||
function clear_class(root, cls) {
|
||||
root.querySelectorAll('.' + cls).forEach(el => el.classList.remove(cls));
|
||||
}
|
||||
|
||||
function set_cell(cell, date, outside) {
|
||||
cell.classList.toggle('dimm', outside);
|
||||
const iso = date.toISOString();
|
||||
cell.setAttribute('datetime', iso);
|
||||
cell.children[0].id = iso;
|
||||
cell.children[0].setAttribute('datetime', iso);
|
||||
cell.children[1].textContent = date.getUTCDate();
|
||||
cell.children[1].setAttribute('for', iso);
|
||||
}
|
||||
|
||||
function render(state, date) {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
const first = get_first_day_of_month(date);
|
||||
const in_cur = new Date(year, month + 1, 0).getDate();
|
||||
const in_prev = new Date(year, month, 0).getDate();
|
||||
const today = new Date();
|
||||
const rows = state.body.querySelectorAll('tr');
|
||||
|
||||
clear_class(state.body, 'rsv-cal-cell-current');
|
||||
clear_class(state.body, 'rsv-cal-cell-today');
|
||||
|
||||
let idx = 0;
|
||||
for (let d = in_prev - first + 1; d <= in_prev; d++, idx++) {
|
||||
const dt = new Date(Date.UTC(year, month - 1, d));
|
||||
const cell = rows[0].children[idx];
|
||||
set_cell(cell, dt, true);
|
||||
if (is_same_day(dt, today)) cell.classList.add('rsv-cal-cell-today');
|
||||
}
|
||||
for (let i = 1; i <= in_cur; i++, idx++) {
|
||||
const dt = new Date(Date.UTC(year, month, i));
|
||||
const cell = rows[Math.floor(idx / 7)].children[idx % 7];
|
||||
set_cell(cell, dt, false);
|
||||
if (is_same_day(dt, date)) cell.querySelector('input').checked = true;
|
||||
if (is_same_day(dt, today)) cell.classList.add('rsv-cal-cell-today');
|
||||
}
|
||||
for (let i = 1; idx < 42; i++, idx++) {
|
||||
const dt = new Date(Date.UTC(year, month + 1, i));
|
||||
const cell = rows[Math.floor(idx / 7)].children[idx % 7];
|
||||
set_cell(cell, dt, true);
|
||||
if (is_same_day(dt, today)) cell.classList.add('rsv-cal-cell-today');
|
||||
}
|
||||
|
||||
state.month_el.textContent =
|
||||
new Date(year, month).toLocaleString(navigator.language, { month: 'long' }) + ' ' + year;
|
||||
}
|
||||
|
||||
function day_names() {
|
||||
// Generate short weekday names starting on Monday using the browser locale.
|
||||
return Array.from({ length: 7 }, (_, i) =>
|
||||
new Date(2024, 0, 1 + i) // 2024-01-01 is a Monday
|
||||
.toLocaleDateString(navigator.language, { weekday: 'short' })
|
||||
);
|
||||
}
|
||||
|
||||
const ARROW_L = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0"/></svg>`;
|
||||
const ARROW_R = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708"/></svg>`;
|
||||
|
||||
function nav_btn(icon, handler) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.innerHTML = icon;
|
||||
btn.classList.add('rsv-cal-btn-nav');
|
||||
btn.addEventListener('click', handler);
|
||||
const wrap = document.createElement('div');
|
||||
wrap.appendChild(btn);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function build_header(state) {
|
||||
const month_el = document.createElement('span');
|
||||
month_el.classList.add('rsv-cal-month');
|
||||
state.month_el = month_el;
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.classList.add('rsv-cal-controls');
|
||||
controls.append(
|
||||
nav_btn(ARROW_L, () => state.set_date(new Date(state.date.getFullYear(), state.date.getMonth() - 1, state.date.getDate()))),
|
||||
month_el,
|
||||
nav_btn(ARROW_R, () => state.set_date(new Date(state.date.getFullYear(), state.date.getMonth() + 1, state.date.getDate())))
|
||||
);
|
||||
|
||||
const ctrl_td = document.createElement('td');
|
||||
ctrl_td.colSpan = 7;
|
||||
ctrl_td.appendChild(controls);
|
||||
|
||||
const ctrl_row = document.createElement('tr');
|
||||
ctrl_row.appendChild(ctrl_td);
|
||||
|
||||
const names_row = document.createElement('tr');
|
||||
day_names().forEach(name => {
|
||||
const th = document.createElement('th');
|
||||
th.textContent = name;
|
||||
names_row.appendChild(th);
|
||||
});
|
||||
|
||||
const header = document.createElement('thead');
|
||||
header.classList.add('rsv-cal-header');
|
||||
header.append(ctrl_row, names_row);
|
||||
return header;
|
||||
}
|
||||
|
||||
function build_body(name, on_select) {
|
||||
const tbody = document.createElement('tbody');
|
||||
tbody.classList.add('rsv-cal-grid');
|
||||
|
||||
for (let y = 0; y < 6; y++) {
|
||||
const row = document.createElement('tr');
|
||||
for (let x = 0; x < 7; x++) {
|
||||
const radio = document.createElement('input');
|
||||
radio.type = 'radio';
|
||||
radio.name = name + '.date';
|
||||
radio.hidden = true;
|
||||
radio.addEventListener('change', on_select);
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.setAttribute('unselectable', 'true');
|
||||
|
||||
const cell = document.createElement('td');
|
||||
cell.classList.add('rsv-cal-cell');
|
||||
cell.append(radio, label);
|
||||
row.appendChild(cell);
|
||||
}
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
return tbody;
|
||||
}
|
||||
|
||||
return {
|
||||
create(container, name) {
|
||||
const state = {
|
||||
date: null,
|
||||
month_el: null,
|
||||
body: null,
|
||||
container,
|
||||
|
||||
set_date(date) {
|
||||
if (this.date !== null && is_same_day(date, this.date)) return;
|
||||
|
||||
const month_changed = this.date === null || !is_same_month(date, this.date);
|
||||
if (month_changed) {
|
||||
const prev = this.body.querySelector('input[type="radio"]:checked');
|
||||
if (prev) prev.checked = false;
|
||||
render(this, date);
|
||||
const next = this.body.querySelector(`input[id="${date.toISOString()}"]`);
|
||||
if (next) next.checked = true;
|
||||
}
|
||||
|
||||
const first = get_first_day_of_month(date);
|
||||
const cell_idx = first + date.getDate() - 1;
|
||||
const rows = this.body.querySelectorAll('tr');
|
||||
rows[Math.floor(cell_idx / 7)].children[cell_idx % 7].querySelector('input').checked = true;
|
||||
|
||||
this.date = date;
|
||||
this.container.value = date;
|
||||
this.container.dispatchEvent(
|
||||
new InputEvent('change', { bubbles: true, cancelable: true, composed: true })
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
container.classList.add('rsv-calendar');
|
||||
|
||||
const table = document.createElement('table');
|
||||
table.appendChild(build_header(state));
|
||||
table.appendChild(build_body(name, e => state.set_date(new Date(e.target.getAttribute('datetime')))));
|
||||
|
||||
state.body = table.querySelector('tbody');
|
||||
container.appendChild(table);
|
||||
state.set_date(new Date());
|
||||
return state;
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* RSV Dynamic datagrid
|
||||
* Allows fetching with JS instead of page reload.
|
||||
*/
|
||||
window.RsvDataGrid = window.RsvDataGrid || {
|
||||
create_header(self, columns, has_actions) {
|
||||
let thead = document.createElement('thead');
|
||||
|
||||
thead.replaceChildren(...Object.entries(columns).map(([key, value]) => {
|
||||
let th = document.createElement('th');
|
||||
|
||||
if (value.width) {
|
||||
th.style.width = value.width + 'px';
|
||||
}
|
||||
|
||||
th.classList.add('manage-columns', 'column-' + key);
|
||||
if (value.is_sortable) {
|
||||
th.classList.add('sortable', key);
|
||||
let button = document.createElement('a');
|
||||
button.onclick = () => {
|
||||
self.sort_by(key);
|
||||
};
|
||||
button.innerHTML =
|
||||
`<span>${value.label}</span>
|
||||
<span class="sorting-indicators">
|
||||
<span class="sorting-indicator asc" aria-hidden="true"></span>
|
||||
<span class="sorting-indicator desc" aria-hidden="true"></span>
|
||||
</span>
|
||||
<span class="screen-reader-text">Sort ascending.</span>
|
||||
`;
|
||||
|
||||
th.appendChild(button);
|
||||
} else {
|
||||
th.innerText = value.label;
|
||||
}
|
||||
return th;
|
||||
}));
|
||||
|
||||
if (has_actions) {
|
||||
let th = document.createElement('th');
|
||||
th.innerText = 'Actions';
|
||||
thead.appendChild(th);
|
||||
}
|
||||
|
||||
return thead;
|
||||
},
|
||||
|
||||
create_footer(self, columns, has_actions) {
|
||||
let tfoot = document.createElement('tfoot');
|
||||
|
||||
let trow = document.createElement('tr');
|
||||
|
||||
trow.replaceChildren(...Object.entries(columns).map(([key, value]) => {
|
||||
let th = document.createElement('th');
|
||||
|
||||
if (value.width) {
|
||||
th.style.width = value.width + 'px';
|
||||
}
|
||||
|
||||
th.classList.add('manage-columns', 'column-' + key);
|
||||
if (value.is_sortable) {
|
||||
th.classList.add('sortable', key);
|
||||
let button = document.createElement('a');
|
||||
button.onclick = () => {
|
||||
self.sort_by(key);
|
||||
};
|
||||
button.innerHTML =
|
||||
`<span>${value.label}</span>
|
||||
<span class="sorting-indicators">
|
||||
<span class="sorting-indicator asc" aria-hidden="true"></span>
|
||||
<span class="sorting-indicator desc" aria-hidden="true"></span>
|
||||
</span>
|
||||
<span class="screen-reader-text">Sort ascending.</span>
|
||||
`;
|
||||
|
||||
th.appendChild(button);
|
||||
} else {
|
||||
th.innerText = value.label;
|
||||
}
|
||||
return th;
|
||||
}));
|
||||
|
||||
tfoot.appendChild(trow);
|
||||
|
||||
return tfoot;
|
||||
},
|
||||
|
||||
create_dg_row(self, data, index = 0) {
|
||||
let row = document.createElement('tr');
|
||||
row.classList.add('iedit', 'author-self', 'level-0', 'type-page', 'status-publish', 'hentry');
|
||||
|
||||
row.replaceChildren(...Object.entries(self.columns).map(([key, value]) => {
|
||||
let td = document.createElement('td');
|
||||
if (self.mappings[key] != null) {
|
||||
td = self.mappings[key](self, row, data);
|
||||
} else {
|
||||
td.innerText = data[key];
|
||||
}
|
||||
|
||||
if(value.actions != null && Object.entries(value.actions).length > 0) {
|
||||
const visible_actions = Object.entries(value.actions).filter(([, action]) =>
|
||||
action.condition == null || action.condition(data, index)
|
||||
);
|
||||
if (visible_actions.length === 0) return td;
|
||||
|
||||
row.classList.add('has-row-actions')
|
||||
const action_cell = document.createElement('div');
|
||||
action_cell.classList.add('row-actions', 'visible');
|
||||
|
||||
const action_spans = visible_actions.map(([key, value]) => {
|
||||
if (value.is_link) {
|
||||
let span = document.createElement('span');
|
||||
let a = document.createElement('a');
|
||||
a.innerText = key;
|
||||
a.href = value.func(data);
|
||||
span.appendChild(a);
|
||||
return span;
|
||||
} else {
|
||||
let span = document.createElement('span');
|
||||
let button = document.createElement('a');
|
||||
button.onclick = function () { value.func(self, row, data) };
|
||||
button.innerText = key;
|
||||
span.appendChild(button);
|
||||
return span;
|
||||
}
|
||||
});
|
||||
const action_nodes = action_spans.flatMap((span, i) =>
|
||||
i < action_spans.length - 1 ? [span, document.createTextNode(' | ')] : [span]
|
||||
);
|
||||
action_cell.replaceChildren(...action_nodes);
|
||||
|
||||
td.appendChild(action_cell);
|
||||
}
|
||||
|
||||
return td;
|
||||
}));
|
||||
|
||||
|
||||
|
||||
return row;
|
||||
},
|
||||
|
||||
|
||||
async render_data_grid(self) {
|
||||
const rows = self.fetch_resource()
|
||||
.then(x => { self.set_total(x.total); return x; })
|
||||
.then(x => x.data.map((x, i) => RsvDataGrid.create_dg_row(self, x, i)))
|
||||
.then(x => self.body.replaceChildren(...x))
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
return []; // empty the rows
|
||||
});
|
||||
},
|
||||
|
||||
link_action(func, condition = null) {
|
||||
return { is_link: true, func, condition };
|
||||
},
|
||||
|
||||
func_action(func, condition = null) {
|
||||
return { is_link: false, func, condition };
|
||||
},
|
||||
|
||||
edit_action(func, condition = null) {
|
||||
return { is_link: false, func, condition };
|
||||
},
|
||||
|
||||
action_column(label, is_sortable, actions) {
|
||||
return {
|
||||
label: label,
|
||||
is_sortable: is_sortable,
|
||||
actions: actions,
|
||||
};
|
||||
},
|
||||
|
||||
column(label, is_sortable = false, width = 0) {
|
||||
return {
|
||||
label: label,
|
||||
is_sortable: is_sortable,
|
||||
width: width
|
||||
};
|
||||
},
|
||||
|
||||
create_paging_button(text) {
|
||||
let button = document.createElement('a');
|
||||
button.classList.add('button');
|
||||
|
||||
let label = document.createElement('span');
|
||||
label.innerText = text;
|
||||
|
||||
button.appendChild(label);
|
||||
|
||||
return button;
|
||||
},
|
||||
|
||||
create_last_page_btn() {
|
||||
let btn = this.create_paging_button("»");
|
||||
btn.classList.add('last-page');
|
||||
return btn;
|
||||
},
|
||||
|
||||
create_next_page_btn() {
|
||||
let btn = this.create_paging_button("›");
|
||||
btn.classList.add('next-page');
|
||||
return btn;
|
||||
},
|
||||
|
||||
create_first_page_btn() {
|
||||
let btn = this.create_paging_button("«");
|
||||
btn.classList.add('first-page');
|
||||
return btn;
|
||||
},
|
||||
|
||||
create_prev_page_btn() {
|
||||
let btn = this.create_paging_button("‹");
|
||||
btn.classList.add('prev-page');
|
||||
return btn;
|
||||
},
|
||||
|
||||
create_paging_text() {
|
||||
let text = document.createElement('span');
|
||||
text.classList.add('paging-input');
|
||||
|
||||
let paging_text = document.createElement('span');
|
||||
paging_text.classList.add('tablenav-paging-text');
|
||||
|
||||
text.appendChild(paging_text);
|
||||
|
||||
const result = {
|
||||
container: text,
|
||||
paging_text: paging_text,
|
||||
};
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
create_paging_controls(self) {
|
||||
let nav = document.createElement('div');
|
||||
nav.classList.add('tablenav-pages');
|
||||
|
||||
let displaying_num = document.createElement('span');
|
||||
displaying_num.classList.add('displaying-num');
|
||||
|
||||
nav.appendChild(displaying_num);
|
||||
|
||||
let pagination_links = document.createElement('span');
|
||||
pagination_links.classList.add('pagination-links');
|
||||
|
||||
let first_page_btn = this.create_first_page_btn();
|
||||
first_page_btn.onclick = () => self.goto_first_page();
|
||||
let prev_page_btn = this.create_prev_page_btn();
|
||||
prev_page_btn.onclick = () => self.move_page(-1);
|
||||
let next_page_btn = this.create_next_page_btn();
|
||||
next_page_btn.onclick = () => self.move_page(1);
|
||||
let last_page_btn = this.create_last_page_btn();
|
||||
last_page_btn.onclick = () => self.goto_last_page();
|
||||
|
||||
pagination_links.appendChild(first_page_btn);
|
||||
pagination_links.appendChild(prev_page_btn);
|
||||
|
||||
let paging_text = this.create_paging_text();
|
||||
|
||||
pagination_links.appendChild(paging_text.container);
|
||||
|
||||
pagination_links.appendChild(next_page_btn);
|
||||
pagination_links.appendChild(last_page_btn);
|
||||
|
||||
// pagination_links.innerHTML = `
|
||||
// <span class="pagination-links">
|
||||
// <span class="tablenav-pages-navspan button disabled" aria-hidden="true">«</span>
|
||||
// <span class="tablenav-pages-navspan button disabled" aria-hidden="true">‹</span>
|
||||
// <span class="screen-reader-text">Current Page</span>
|
||||
// <span id="table-paging" class="paging-input">
|
||||
// <span class="tablenav-paging-text">1 of <span class="total-pages">2</span></span>
|
||||
// </span>
|
||||
// <a class="next-page button" href="http://127.0.0.1/wordpress/wp-admin/edit-tags.php?taxonomy=category&paged=2">
|
||||
// <span class="screen-reader-text">Next page</span>
|
||||
// <span aria-hidden="true">›</span>
|
||||
// </a>
|
||||
// <a class="last-page button" href="http://127.0.0.1/wordpress/wp-admin/edit-tags.php?taxonomy=category&paged=2">
|
||||
// <span class="screen-reader-text">Last page</span>
|
||||
// <span aria-hidden="true">»</span>
|
||||
// </a>
|
||||
// </span>`;
|
||||
|
||||
nav.appendChild(pagination_links);
|
||||
|
||||
const paging_controls = {
|
||||
container: nav,
|
||||
paging_text: paging_text.paging_text,
|
||||
display_num: displaying_num,
|
||||
};
|
||||
|
||||
return paging_controls;
|
||||
},
|
||||
|
||||
create_data_grid(container, resource, columns, actions) {
|
||||
let tbody = document.createElement('tbody');
|
||||
|
||||
let state = {
|
||||
columns: columns,
|
||||
resource: resource,
|
||||
actions: actions,
|
||||
body: tbody,
|
||||
mappings: {},
|
||||
params: {},
|
||||
total: 0,
|
||||
page: 0,
|
||||
page_size: 20,
|
||||
container: container,
|
||||
order_by: null,
|
||||
order: 0,
|
||||
fetch_resource() {
|
||||
const params = { ...this.params };
|
||||
if (this.order_by) {
|
||||
params.orderby = this.order_by;
|
||||
params.order = this.order === 0 ? 'desc' : 'asc';
|
||||
}
|
||||
return this.resource.get_page(this.page * this.page_size, this.page_size, params);
|
||||
},
|
||||
refresh() {
|
||||
RsvDataGrid.render_data_grid(this)
|
||||
},
|
||||
refresh_row(row, data) {
|
||||
let row2 = RsvDataGrid.create_dg_row(this, data);
|
||||
row.replaceWith(row2);
|
||||
},
|
||||
map_column(key, func) {
|
||||
this.mappings[key] = func;
|
||||
return this;
|
||||
},
|
||||
add_action(key, func) {
|
||||
this.actions[key] = func;
|
||||
return this;
|
||||
},
|
||||
set_param(key, value) {
|
||||
this.params[key] = value;
|
||||
},
|
||||
remove_param(key, do_refresh = true) {
|
||||
delete this.params[key];
|
||||
if (do_refresh) {
|
||||
this.refresh();
|
||||
}
|
||||
},
|
||||
sort_by(key, dir = null, do_refresh = true) {
|
||||
let ths = this.container.getElementsByClassName("column-" + key);
|
||||
if (ths.length > 0) {
|
||||
this.order = dir ?? (this.order_by === key ? 1 - this.order : 0);
|
||||
this.order_by = key;
|
||||
|
||||
let th = ths[0];
|
||||
if (this.order === 0) {
|
||||
th.classList.remove('asc');
|
||||
th.classList.add('desc');
|
||||
} else {
|
||||
th.classList.remove('desc');
|
||||
th.classList.add('asc');
|
||||
}
|
||||
|
||||
// if (th.classList.contains('asc')) {
|
||||
// th.classList.replace('asc', 'desc');
|
||||
// this.set_param('order', 'desc');
|
||||
// } else {
|
||||
// th.classList.remove('desc');
|
||||
// th.classList.add('asc');
|
||||
// this.set_param('order', 'asc');
|
||||
// }
|
||||
let sorted = th.parentElement.getElementsByClassName('sorted');
|
||||
if (sorted.length > 0)
|
||||
sorted[0].classList.replace('sorted', 'sortable');
|
||||
th.classList.replace('sortable', 'sorted');
|
||||
|
||||
if (do_refresh) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
},
|
||||
set_total(count) {
|
||||
this.total = count;
|
||||
this.paging_controls.display_num.innerHTML = `${this.total} položek`;
|
||||
this.paging_controls.paging_text.innerHTML = `${this.page + 1} of <span class="total-pages">${Math.ceil(count / this.page_size)}</span>`;
|
||||
},
|
||||
get_total() {
|
||||
return this.total;
|
||||
},
|
||||
move_page(relative) {
|
||||
this.page = Math.max(0, Math.min(this.page + relative, Math.ceil(this.total / this.page_size) - 1));
|
||||
this.refresh();
|
||||
},
|
||||
goto_first_page() {
|
||||
this.move_page(-this.page);
|
||||
},
|
||||
goto_last_page() {
|
||||
this.move_page(Math.ceil(this.total / this.page_size) - this.page - 1);
|
||||
},
|
||||
};
|
||||
|
||||
let paging_controls = this.create_paging_controls(state);
|
||||
|
||||
let footer = document.createElement('div');
|
||||
footer.classList.add('tablenav', 'bottom');
|
||||
footer.appendChild(paging_controls.container);
|
||||
|
||||
state.paging_controls = paging_controls;
|
||||
|
||||
let table = document.createElement('table');
|
||||
table.classList.add('datagrid', 'wp-list-table', 'widefat', 'fixed', 'striped', 'table-view-list');
|
||||
// const has_actions = Object.entries(actions).length > 0;
|
||||
table.appendChild(this.create_header(state, columns, false));
|
||||
|
||||
table.appendChild(tbody);
|
||||
|
||||
table.appendChild(this.create_footer(state, columns, false));
|
||||
|
||||
container.appendChild(table);
|
||||
|
||||
container.appendChild(footer);
|
||||
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
class RsvReservationSelector extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ['timetable-id', 'name', 'price-per-block'];
|
||||
}
|
||||
|
||||
// ---- Attribute accessors ------------------------------------------------
|
||||
|
||||
get timetableId() { return parseInt(this.getAttribute('timetable-id')); }
|
||||
get inputName() { return this.getAttribute('name') ?? 'reservation'; }
|
||||
get pricePerBlock() { return parseFloat(this.getAttribute('price-per-block')) || 0; }
|
||||
|
||||
// ---- Lifecycle ----------------------------------------------------------
|
||||
|
||||
connectedCallback() {
|
||||
this._slots = [];
|
||||
this.classList.add('rsv-timetable-selector');
|
||||
this._build();
|
||||
}
|
||||
|
||||
attributeChangedCallback(_attr, oldVal, newVal) {
|
||||
if (oldVal === null || oldVal === newVal || !this.isConnected) return;
|
||||
this._build();
|
||||
}
|
||||
|
||||
// ---- Public API ---------------------------------------------------------
|
||||
|
||||
getValue() {
|
||||
return {
|
||||
timetable_id: this.timetableId,
|
||||
timetable_reservations: this._slots.map(s => s.start_utc),
|
||||
};
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.querySelectorAll('.rsv-slots-slot-selected').forEach(s => s.classList.remove('rsv-slots-slot-selected'));
|
||||
this._slots = [];
|
||||
this._commit();
|
||||
}
|
||||
|
||||
// ---- Private ------------------------------------------------------------
|
||||
|
||||
_build() {
|
||||
this._slots = [];
|
||||
this.replaceChildren();
|
||||
|
||||
const tid = document.createElement('input');
|
||||
tid.type = 'hidden';
|
||||
tid.name = `${this.inputName}.timetable_id`;
|
||||
tid.value = this.timetableId;
|
||||
this.appendChild(tid);
|
||||
|
||||
const cal_el = document.createElement('div');
|
||||
cal_el.classList.add('rsv-calendar');
|
||||
|
||||
// Create rsv-timeline with timetable-id set before appending so
|
||||
// connectedCallback sees the correct attribute on first render.
|
||||
const time_el = document.createElement('rsv-timeline');
|
||||
time_el.setAttribute('timetable-id', this.timetableId);
|
||||
|
||||
this.append(cal_el, time_el);
|
||||
|
||||
this._calendar = RsvCalendarPicker.create(cal_el, this.inputName);
|
||||
|
||||
// Date change: clear selection, then push new date to the timeline element.
|
||||
cal_el.addEventListener('change', () => {
|
||||
this.querySelectorAll('.rsv-slots-slot-selected').forEach(s => s.classList.remove('rsv-slots-slot-selected'));
|
||||
this._slots = [];
|
||||
this._commit();
|
||||
time_el.date = this._calendar.date;
|
||||
});
|
||||
|
||||
// Slot toggle: read selected slots from timeline, then commit.
|
||||
time_el.addEventListener('input', e => {
|
||||
e.stopPropagation();
|
||||
this._slots = Array.from(time_el.querySelectorAll('.rsv-slots-slot-selected')).map(s => ({
|
||||
start_utc: s.dataset.start_utc,
|
||||
end_utc: s.dataset.end_utc,
|
||||
}));
|
||||
this._commit();
|
||||
});
|
||||
|
||||
this._commit();
|
||||
}
|
||||
|
||||
_commit() {
|
||||
const name = this.inputName;
|
||||
|
||||
this.querySelectorAll(`input[name="${name}.timetable_reservations[]"]`).forEach(i => i.remove());
|
||||
|
||||
let json = [];
|
||||
this._slots.forEach(slot => {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'hidden';
|
||||
inp.name = `${name}.timetable_reservations[]`;
|
||||
inp.value = slot.start_utc;
|
||||
this.appendChild(inp);
|
||||
json.push(slot.start_utc);
|
||||
});
|
||||
|
||||
this.value = JSON.stringify({
|
||||
"timetable_id": this.timetableId,
|
||||
"timetable_reservations": json
|
||||
});
|
||||
|
||||
this.dispatchEvent(new CustomEvent('rsv:slots-changed', {
|
||||
bubbles: true,
|
||||
detail: {
|
||||
name,
|
||||
slots: this._slots,
|
||||
price_per_block: this.pricePerBlock,
|
||||
value: this.getValue(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('rsv-reservation-selector', RsvReservationSelector);
|
||||
@@ -0,0 +1,100 @@
|
||||
class RsvReservationSummary extends HTMLElement {
|
||||
|
||||
// ---- Lifecycle ----------------------------------------------------------
|
||||
|
||||
connectedCallback() {
|
||||
this._all_slots = new Map(); // name → { slots, price_per_block }
|
||||
this._form = this.closest('form');
|
||||
this._build();
|
||||
|
||||
if (this._form) {
|
||||
this._handler = e => {
|
||||
this._all_slots.set(e.detail.name, {
|
||||
slots: e.detail.slots,
|
||||
price_per_block: e.detail.price_per_block,
|
||||
});
|
||||
this._render();
|
||||
};
|
||||
this._form.addEventListener('rsv:slots-changed', this._handler);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if (this._form && this._handler) {
|
||||
this._form.removeEventListener('rsv:slots-changed', this._handler);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Private ------------------------------------------------------------
|
||||
|
||||
_build() {
|
||||
const s = ReservairStrings.summary;
|
||||
this.innerHTML = `
|
||||
<div class="rsv-summary-header">
|
||||
<span class="rsv-summary-title">${s.title}</span>
|
||||
<button type="button" class="rsv-summary-clear">${s.clear_all}</button>
|
||||
</div>
|
||||
<ul class="rsv-summary-list"></ul>
|
||||
<div class="rsv-summary-footer">
|
||||
<span class="rsv-summary-count"></span>
|
||||
<div class="rsv-summary-price"></div>
|
||||
</div>
|
||||
`;
|
||||
this.hidden = true;
|
||||
|
||||
this.querySelector('.rsv-summary-clear').addEventListener('click', () => {
|
||||
this._form?.querySelectorAll('rsv-reservation-selector').forEach(sel => sel.clear());
|
||||
});
|
||||
}
|
||||
|
||||
_render() {
|
||||
const all_slots = [...this._all_slots.values()].flatMap(({ slots, price_per_block }) =>
|
||||
slots.map(s => ({ ...s, price_per_block }))
|
||||
);
|
||||
console.log(all_slots);
|
||||
|
||||
const n = all_slots.length;
|
||||
const list = this.querySelector('.rsv-summary-list');
|
||||
const count_el = this.querySelector('.rsv-summary-count');
|
||||
const price_el = this.querySelector('.rsv-summary-price');
|
||||
const s = ReservairStrings.summary;
|
||||
const locale = navigator.language;
|
||||
|
||||
this.hidden = n === 0;
|
||||
if (n === 0) {
|
||||
list.replaceChildren();
|
||||
count_el.textContent = '';
|
||||
price_el.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const time_opts = { hour: '2-digit', minute: '2-digit' };
|
||||
list.replaceChildren(...all_slots.map(slot => {
|
||||
const start = new Date(slot.start_utc);
|
||||
const end = new Date(slot.end_utc);
|
||||
const li = document.createElement('li');
|
||||
li.className = 'rsv-summary-item';
|
||||
li.innerHTML = `
|
||||
<div class="rsv-summary-item-info">
|
||||
<span class="rsv-summary-item-date">${start.toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long' })}</span>
|
||||
<span class="rsv-summary-item-time">${start.toLocaleTimeString(locale, time_opts)} – ${end.toLocaleTimeString(locale, time_opts)}</span>
|
||||
</div>
|
||||
${slot.price_per_block > 0 ? `<span class="rsv-summary-item-price">${slot.price_per_block} ${s.currency}</span>` : ''}
|
||||
`;
|
||||
return li;
|
||||
}));
|
||||
|
||||
const total = all_slots.reduce((sum, slot) => sum + slot.price_per_block, 0);
|
||||
count_el.textContent = this._fmt_count(n);
|
||||
price_el.textContent = total > 0 ? `${total} ${s.currency}` : '';
|
||||
}
|
||||
|
||||
_fmt_count(n) {
|
||||
const s = ReservairStrings.summary;
|
||||
if (n === 1) return s.count_one;
|
||||
if (n < 5) return s.count_few.replace('%d', n);
|
||||
return s.count_many.replace('%d', n);
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('rsv-reservation-summary', RsvReservationSummary);
|
||||
@@ -0,0 +1,141 @@
|
||||
class RsvTimeline extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ['timetable-id', 'date'];
|
||||
}
|
||||
|
||||
// ---- Attribute accessors ------------------------------------------------
|
||||
|
||||
get timetableId() { return parseInt(this.getAttribute('timetable-id')); }
|
||||
|
||||
get date() {
|
||||
const attr = this.getAttribute('date');
|
||||
// Parse as local midnight so setHours() in block rendering stays in local time.
|
||||
return attr ? new Date(attr + 'T12:00:00') : new Date();
|
||||
}
|
||||
|
||||
set date(value) {
|
||||
const d = new Date(value);
|
||||
const str = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
this.setAttribute('date', str);
|
||||
}
|
||||
|
||||
// ---- Lifecycle ----------------------------------------------------------
|
||||
|
||||
connectedCallback() {
|
||||
this._version = 0;
|
||||
this.classList.add('rsv-slots-list');
|
||||
this.addEventListener('click', this._on_click.bind(this));
|
||||
this._render();
|
||||
}
|
||||
|
||||
attributeChangedCallback(_attr, oldVal, newVal) {
|
||||
if (oldVal === newVal || !this.isConnected) return;
|
||||
this._render();
|
||||
}
|
||||
|
||||
// ---- Private ------------------------------------------------------------
|
||||
|
||||
_on_click(event) {
|
||||
const slot = event.target.closest('.rsv-slots-slot');
|
||||
if (slot && !slot.classList.contains('rsv-slots-slot-full')) {
|
||||
slot.classList.toggle('rsv-slots-slot-selected');
|
||||
slot.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
async _render() {
|
||||
// Version guard: discard renders that were superseded by a newer call.
|
||||
const v = ++this._version;
|
||||
const s = ReservairStrings.timeline;
|
||||
|
||||
if (this.timetableId === null) {
|
||||
this.replaceChildren(this._notice(s.not_reservable));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const occupancy = await RsvTimetableService.get_availability_for_date(this.timetableId, this.date);
|
||||
if (v !== this._version) return;
|
||||
|
||||
if(occupancy.length === 0) {
|
||||
this.replaceChildren(this._notice(s.no_blocks));
|
||||
return;
|
||||
}
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.classList.add('rsv-slots-label');
|
||||
header.textContent = this.date.toLocaleDateString(navigator.language, {
|
||||
weekday: 'long', day: 'numeric', month: 'long',
|
||||
}).replace(',', '');
|
||||
|
||||
const blocks = [];
|
||||
|
||||
for (const { from_minutes, to_minutes, block_size_in_minutes, occupancy: block_occ } of occupancy) {
|
||||
if (from_minutes === to_minutes || block_occ.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const from_block = parseInt(from_minutes) / block_size_in_minutes;
|
||||
|
||||
const time_slots = block_occ.map((occ, i) =>
|
||||
this._block(this.date, occ, block_size_in_minutes, from_block + i)
|
||||
);
|
||||
|
||||
const time_slot_group = document.createElement('div');
|
||||
time_slot_group.classList.add('rsv-slots-group');
|
||||
time_slot_group.replaceChildren(...time_slots);
|
||||
blocks.push(time_slot_group);
|
||||
}
|
||||
|
||||
this.replaceChildren(header, ...blocks);
|
||||
} catch (_e) {
|
||||
if (v !== this._version) return;
|
||||
this.replaceChildren(this._notice(s.no_blocks));
|
||||
}
|
||||
}
|
||||
|
||||
_block(date, left, block_size, idx) {
|
||||
const from = new Date(date);
|
||||
from.setHours(0, idx * block_size, 0, 0);
|
||||
|
||||
const to = new Date(from);
|
||||
to.setMinutes(to.getMinutes() + block_size);
|
||||
|
||||
const cell = document.createElement('div');
|
||||
cell.classList.add('rsv-slots-slot', 'rsv-slots-slot-available');
|
||||
cell.dataset.start_utc = from.toISOString();
|
||||
cell.dataset.end_utc = to.toISOString();
|
||||
if (left === 0) cell.classList.add('rsv-slots-slot-full');
|
||||
|
||||
const time_el = document.createElement('span');
|
||||
time_el.classList.add('rsv-slots-slot-time');
|
||||
time_el.textContent = `${this._fmt(from)} – ${this._fmt(to)}`;
|
||||
|
||||
const badge = document.createElement('span');
|
||||
badge.classList.add('rsv-slots-slot-badge');
|
||||
const remaining_seats = left;
|
||||
|
||||
if (remaining_seats > 0) badge.classList.add('rsv-slots-slot-badge-available');
|
||||
|
||||
if (remaining_seats === 1) badge.textContent = `${remaining_seats} místo`;
|
||||
else if (remaining_seats >= 2 && remaining_seats <= 4) badge.textContent = `${remaining_seats} místa`;
|
||||
else badge.textContent = `${remaining_seats} míst`;
|
||||
|
||||
|
||||
cell.append(time_el, badge);
|
||||
return cell;
|
||||
}
|
||||
|
||||
_notice(text) {
|
||||
const p = document.createElement('p');
|
||||
p.classList.add('rsv-slots-notice');
|
||||
p.textContent = text;
|
||||
return p;
|
||||
}
|
||||
|
||||
_fmt(dt) {
|
||||
return dt.getHours() + ':' + String(dt.getMinutes()).padStart(2, '0');
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('rsv-timeline', RsvTimeline);
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
const RsvTimetableService = {
|
||||
get_all() {
|
||||
return fetch(get_rest_url('timetable'), { method: 'GET' })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`fetch timetables failed: ${r.status}`);
|
||||
return r.json();
|
||||
});
|
||||
},
|
||||
|
||||
get_availability_for_date(timetable_id, date) {
|
||||
const params = new URLSearchParams({
|
||||
date: date.toISOString().slice(0, 10),
|
||||
});
|
||||
|
||||
return fetch(get_rest_url(`timetable/${timetable_id}/availability?${params}`), { method: 'GET' })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error(`fetch availability failed: ${r.status}`);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user