(#2) - forms improvements (#4)

Co-authored-by: Martin Slachta <martin.slachta@outlook.com>
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-06-12 14:05:49 +00:00
parent 1294a177ae
commit f4d3972d07
17 changed files with 1152 additions and 1129 deletions
+4 -1
View File
@@ -1,4 +1,7 @@
{
"$schema": "/phpactor.schema.json",
"language_server_psalm.enabled": true
"language_server_psalm.enabled": true,
"indexer.stub_paths": [
"%project_root%/vendor/php-stubs/wordpress-stubs"
]
}
+67
View File
@@ -0,0 +1,67 @@
# Forms
The reservation is mostly created by filling in a form. For that reason this plugin has it's own _form definition_ feature.
The form definition is an array of element it contains. Each element has a *type*, for example: `input-text`, `input-reservation`, etc.
Keep the form structure and content separate -> when working with the form, take time to figure, if you are working with structure, or existence of something within.
## Submitting
When user submits the form, the `RsvFormSubmitter.js` is called. It collects the values, which we describe in more detail, and then sends it using `fetch` as POST to `reservations/forms/{form_id}`.
### Collecting
The *collecting* is a process with `<form>` DOM subtree as an _input_ and valid input JSON for the defined form at `form_id` as _output_.
We decided to only consider *linearized* form, therefore an single-dimension array of fields. We find little to no value to define JSON structure in the form itself, but rather define linear array of inputs, where each input value is a JSON itself. We explaing why that is beneficial.
1. Even atomic values like `"hello"` or `69` are valid JSON format. This means the collector can assume every value is a JSON and use `JSON.parse(input.value)`.
2. We can change the form structure, but the output remains the same. The use-case is for example, grouping fields for First and Last name into one row, but still keep them separate attributes in final JSON. On the other hand, the country code and telephone number are two fields on the same row, but should be one attribute in the final JSON. Both cases reflect only in the DOM structure.
### Contract
For element to be collected, it has to comply to a contract. Namely, it must have a `rsv-form-field` class and have a `value` attribute. The tagging with `rsv-form-field` class allows that by default, no custom defined component is collected. Therefore you can compose new elements from existing ones and only tag the outer-most as `rsv-form-field`. For example:
```
<rsv-reservation-collector class="rsv-form-field">
<rsv-calendar/>
<rsv-time-slot-selector/>
</rsv-reservation-collector>
```
If the collector would collect exact elements, like `<input>`, it would:
a) require a register
b) be unpredictable
c) require some form of filtering
## Element handlers
Element handler is a PHP class extending `RsvFormElementHandler` class. The parent class has two abstract methods: `draw` & `submit`. The `draw` method is called when the form is being rendered on the backend for the user. The caller is the `RsvFormRenderer` that does not try hard to catch all errors, so be careful with putting logic to `draw`.
The other method `submit` is called by the `RsvFormProcessor`. It definitely should validate the value, but it can also do other things. For example, the element for reservation saves the reservation to the database.
You might have noticed in a reservation example one major flaw. What happens, when any of the next elements fail and cause the whole form _unworthy of submission_? The error handling itself and propagating back to the user is described later. For now let's focus on handling the error correctly.
We thought of two approaches: separate validation & submission steps and rollback. The first approach will not actually solve the issue. It might eliminate some cases, but sometimes error slips through and cause exception in the submission step. For example suppose creating reservation. The validation step can decide it is okay and the time block is available. But right after that another request creates the reservation in the same time block.
We could either do same validation in the submission step, but then, what is the point of the validation step. Another solution is to create a token for the time block. The second solution requires one important thing, the element must know it is an element, to implement the safety gates correctly.
The second approach is using a rollback, that does so when any of the next elements fail and cause the whole form _unworthy of submission_. This way only the element handler has to implement the safe gate.
TODO: generalize it using a property the element submission must have
## Register
Register is a string to object mapping, that maps element IDs to instantiated objects of the handlers. This allows to customize the handler by changing it's state. For example, there is a handler for input text. The constructor can have a predicate lambda that does the validation and allow simple implementation of email, telephone number or any other validation. Or it can be wrapped with regular expression.
## Error handling
When an error occurs, the backend should send back an error response, so that user knowns what he did wrong. The response should contain element's ID, that detected the issue and error ID. The reason why not use a message is for internationalization. The form processor does not know and must not know the user's culture. The internationalization is a separate concern and should be done mapping error ID to a particular message.
Last thing inspired by Problem Details RFC are extensions. The response can contain a dictionary mapping strings to strings that contains structured data about the error. For example, only one time slot of multiple can be occupied. The extensions could contain value of the occupied time slot and the response handler could use it to provide a more detailed error message.
## Success handling
Even success must be handled. The user must know that the submission is successfully finished. The form definition can contain a success message as HTML.
+3 -3
View File
@@ -1,7 +1,7 @@
/* ─── Two-column admin layout (Forms page) ──────────────────────────────── */
/* ─── Column layouts (RsvColumnLayout) ──────────────────────────────────── */
/*#col-left { width: 30%; }
#col-right { width: 70%; }*/
.rsv-cols { display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: flex-start; }
.rsv-cols > .rsv-col { flex: var(--rsv-col-grow, 1) 1 0; min-width: 18rem; }
/* ─── Inline detail expand row (Reservations page) ──────────────────────── */
+3 -2
View File
@@ -103,10 +103,10 @@
border-color: var(--color-blue-500);
}
.rsv-form-input input:user-invalid {
/*.rsv-form-input input:user-invalid {
border-color: var(--color-red-500);
box-shadow: 0 0 0 4px color-mix(in oklab,var(--color-red-500)25%,transparent);
}
}*/
.rsv-form-section {
margin-bottom: var(--s-5);
@@ -159,6 +159,7 @@
padding-left: 5pt;
}
.rsv-form-input:user-invalid,
.rsv-invalid {
border-color: var(--color-red-500) !important;
box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-red-500) 25%, transparent) !important;
+1 -1
View File
@@ -77,7 +77,7 @@ label.rsv-slots-slot-time>input:checked + .content>.capacity {
margin-top: 0.375rem;
border: 1.5px solid #e8e8e8;
border-radius: 10px;
padding: 9px 12px;
padding: 0.25rem;
cursor: pointer;
transition: border-color .12s, background .12s, color .12s;
display: flex;
+3 -6
View File
@@ -12,17 +12,14 @@
"files": [
"includes/RsvAdminMenuDefinition.php",
"includes/RsvAssetsDefinition.php",
"includes/RsvRestApiDefinition.php",
"includes/Views/RsvFormsPage.php",
"includes/Views/RsvReservationsPage.php",
"includes/Views/RsvTimetablePage.php",
"includes/Views/RsvGoogleCalendarSettingsPage.php"
"includes/RsvRestApiDefinition.php"
]
},
"require-dev": {
"johnpbloch/wordpress-core": "^6.9",
"vimeo/psalm": "^6.16",
"humanmade/psalm-plugin-wordpress": "^3.1"
"humanmade/psalm-plugin-wordpress": "^3.1",
"php-stubs/wordpress-stubs": "^6.9"
},
"scripts": {
"lint": ["phpcs", "phpstan analyse", "psalm --find-dead-code"]
Generated
+1 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "c4e0cf49edc636becbde269300f26001",
"content-hash": "b4f5229f78cd0eed0c7166614bf05110",
"packages": [
{
"name": "chillerlan/php-qrcode",
+9 -4
View File
@@ -4,12 +4,17 @@
* Contains definitions of the admin menus
*/
function rsv_admin_menu_definition() {
$reservations = new RsvReservationsPage();
$forms = new RsvFormsPage();
$timetable = new RsvTimetablePage();
$google_cal = new RsvGoogleCalendarSettingsPage();
add_menu_page(
'Reservations Settings', // Page title
'Reservations', // Menu title
RsvCapabilities::MANAGE, // Capability
'reservations-settings', // Menu slug
'rsv_reservations_page', // Callback
[$reservations, 'render'], // Callback
'dashicons-calendar', // Icon
20 // Position
);
@@ -20,7 +25,7 @@ function rsv_admin_menu_definition() {
'Forms',
RsvCapabilities::MANAGE,
'forms-settings',
'rsv_forms_page'
[$forms, 'render']
);
add_submenu_page(
@@ -29,7 +34,7 @@ function rsv_admin_menu_definition() {
'Timetables',
RsvCapabilities::MANAGE,
'timetable-settings',
'rsv_timetable_page'
[$timetable, 'render']
);
add_submenu_page(
@@ -38,6 +43,6 @@ function rsv_admin_menu_definition() {
'Google Calendar',
RsvCapabilities::MANAGE,
'rsv-google-calendar',
'rsv_google_calendar_settings_page'
[$google_cal, 'render']
);
}
+15
View File
@@ -0,0 +1,15 @@
<?php
abstract class RsvAdminPage {
final public function render(): void {
if (!current_user_can(RsvCapabilities::MANAGE)) {
return;
}
echo '<div class="wrap">';
$this->render_content();
echo '</div>';
}
abstract protected function render_content(): void;
}
+340 -336
View File
@@ -1,284 +1,42 @@
<?php
use Reservair\Forms\RsvFormBuilder;
use Reservair\Layout\RsvColumnLayout;
// Shared inline script for the elements data grid.
// $elements_with_ids: array of element objects already carrying an 'id' key.
// $next_id: the first integer not yet used as an id.
function rsv_elements_table_script(array $elements_with_ids, int $next_id, string $form_id, array $element_types, array $timetables = []): void {
$elements_json = json_encode($elements_with_ids);
$types_json = json_encode(array_values($element_types));
$timetables_json = json_encode(array_values($timetables));
?>
<script>
const rsv_element_types = <?= $types_json ?>;
const rsv_timetables = <?= $timetables_json ?>;
class RsvFormsPage extends RsvAdminPage {
const RSV_EMAIL_DEFAULTS = {
accepted_subject: <?= json_encode('Rezervace přijata') ?>,
accepted_body: <?= json_encode("<h1>Vaše rezervace byla přijata</h1>\n<p>Vaše rezervace byla schválena. Těšíme se na vás!</p>") ?>,
refused_subject: <?= json_encode('Rezervace zamítnuta') ?>,
refused_body: <?= json_encode("<h1>Vaše rezervace byla zamítnuta</h1>\n<p>Vaše rezervace bohužel nebyla schválena. Zkuste prosím jiný termín.</p>") ?>,
};
const rsv_elements_source = (function(initial_items, next_id_start) {
const items = initial_items;
let next_id = next_id_start;
return {
get_page(skip, limit) {
skip = skip ?? 0;
limit = limit ?? 20;
return Promise.resolve({ total: items.length, data: items.slice(skip, skip + limit) });
},
put(id, data) {
const idx = items.findIndex(e => e.id === id);
if (idx === -1) return Promise.reject(new Error('Element not found'));
// Destructure reservation-specific fields so they don't bleed into extra_attrs.
const { id: _id, name: _n, label: _l, type: _t, desc: _d, required: _r,
price_per_block: _p, email_templates: _et, timetable_id: _ti, ...extra_attrs } = items[idx];
items[idx] = {
...extra_attrs,
id,
name: data.name ?? '',
label: data.label ?? '',
type: data.type ?? 'text',
desc: data.desc ?? '',
required: data.required === 'on',
...(data.type === 'input-text' ? {
validation: data.validation ?? '',
pattern: data.pattern ?? '',
pattern_message: data.pattern_message ?? '',
} : {}),
...(data.type === 'reservation' ? {
timetable_id: data.timetable_id ? parseInt(data.timetable_id) : null,
price_per_block: parseFloat(data.price_per_block ?? '0') || 0,
email_templates: {
on_accepted: {
enabled: !!data.email_accepted_enabled,
subject: data.email_accepted_subject ?? RSV_EMAIL_DEFAULTS.accepted_subject,
body: data.email_accepted_body ?? RSV_EMAIL_DEFAULTS.accepted_body,
},
on_refused: {
enabled: !!data.email_refused_enabled,
subject: data.email_refused_subject ?? RSV_EMAIL_DEFAULTS.refused_subject,
body: data.email_refused_body ?? RSV_EMAIL_DEFAULTS.refused_body,
},
},
} : {}),
};
return Promise.resolve(items[idx]);
},
add() {
const item = { id: next_id++, name: '', label: '', type: 'text', desc: '', required: false };
items.push(item);
return item;
},
move_up(id) {
const idx = items.findIndex(e => e.id === id);
if (idx > 0) [items[idx - 1], items[idx]] = [items[idx], items[idx - 1]];
},
move_down(id) {
const idx = items.findIndex(e => e.id === id);
if (idx !== -1 && idx < items.length - 1) [items[idx], items[idx + 1]] = [items[idx + 1], items[idx]];
},
remove(id) {
const idx = items.findIndex(e => e.id === id);
if (idx !== -1) items.splice(idx, 1);
},
get_all() {
return items.map(({ id, ...rest }) => rest);
},
};
})(<?= $elements_json ?>, <?= $next_id ?>);
function rsv_render_element_inline_form(dt, row, data) {
const builder = RsvInlineFormBuilder.create(rsv_elements_source)
.fieldset('Element', '50%')
.input_text('name', 'Slug', data?.name ?? '')
.input_text('label', 'Label', data?.label ?? '')
.input_select('type', 'Type', rsv_element_types, data?.type ?? rsv_element_types[0])
.fieldset('Options', '50%')
.input_text('desc', 'Description', data?.desc ?? '')
.input_checkbox('required', 'Required', data?.required ?? false);
if ((data?.type ?? rsv_element_types[0]) === 'reservation') {
const accepted = data?.email_templates?.on_accepted ?? {};
const refused = data?.email_templates?.on_refused ?? {};
const timetable_options = [
{ value: '', label: '— none —' },
...rsv_timetables.map(t => ({ value: t.id, label: t.name })),
];
builder
.input_select('timetable_id', 'Timetable', timetable_options, data?.timetable_id ?? '')
.input_number('price_per_block', 'Price per block', data?.price_per_block ?? 0)
.fieldset('Email — accepted', '100%')
.input_checkbox('email_accepted_enabled', 'Send email when accepted', accepted.enabled ?? true)
.input_text('email_accepted_subject', 'Subject', accepted.subject ?? RSV_EMAIL_DEFAULTS.accepted_subject)
.input_textarea('email_accepted_body', 'Body', accepted.body ?? RSV_EMAIL_DEFAULTS.accepted_body)
.fieldset('Email — refused', '100%')
.input_checkbox('email_refused_enabled', 'Send email when refused', refused.enabled ?? true)
.input_text('email_refused_subject', 'Subject', refused.subject ?? RSV_EMAIL_DEFAULTS.refused_subject)
.input_textarea('email_refused_body', 'Body', refused.body ?? RSV_EMAIL_DEFAULTS.refused_body);
protected function render_content(): void {
if (isset($_GET['action']) && $_GET['action'] === 'edit' && isset($_GET['id'])) {
$this->show_edit(intval($_GET['id']));
return;
}
if ((data?.type ?? rsv_element_types[0]) === 'input-text') {
builder
.input_select('validation', 'Validation', [
{ value: '', label: '— none —' },
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone' },
{ value: 'digits', label: 'Digits only' },
{ value: 'pattern', label: 'Custom pattern' },
], data?.validation ?? '')
.input_text('pattern', 'Custom pattern (regex)', data?.pattern ?? '')
.show_if(RsvInlineFormBuilder.match_p('validation', 'pattern'))
.input_text('pattern_message', 'Pattern error message', data?.pattern_message ?? '')
.show_if(RsvInlineFormBuilder.match_p('validation', 'pattern'));
}
const node = builder.build({
id: data?.id,
colspan: 5,
save_label: 'Save',
on_success: () => elements_dt.refresh(),
on_cancel: () => elements_dt.refresh(),
});
// Type swaps whole fieldsets, so re-render the inline form on change.
// Common fields are carried over; type-specific fields reset to stored data.
const type_select = node.querySelector('select[name="type"]');
if (type_select) {
type_select.addEventListener('change', () => {
const current = Object.fromEntries(new FormData(node.querySelector('form')));
const merged = {
...data,
name: current.name ?? data?.name,
label: current.label ?? data?.label,
desc: current.desc ?? data?.desc,
required: 'required' in current,
type: type_select.value,
};
row.replaceChildren(rsv_render_element_inline_form(dt, row, merged));
});
}
return node;
$this->show_list();
}
// The elements data grid only exists on the edit page. Guard it so a missing
// container can't abort the script and strip the form's submit handler.
const elements_table_el = document.getElementById('form_elements_table');
if (elements_table_el) {
var elements_dt = RsvDataGrid.create_data_grid(
elements_table_el,
rsv_elements_source,
{
'name': RsvDataGrid.action_column('Name', false, {
'Edit': RsvDataGrid.edit_action(function(dt, row, data) {
row.classList.add(
'inline-edit-row', 'inline-edit-row-post',
'quick-edit-row', 'quick-edit-row-post',
'inline-edit-post', 'inline-editor'
);
row.replaceChildren(rsv_render_element_inline_form(dt, row, data));
}),
'Move Up': RsvDataGrid.func_action(function(dt, row, data) {
rsv_elements_source.move_up(data.id);
dt.refresh();
}),
'Move Down': RsvDataGrid.func_action(function(dt, row, data) {
rsv_elements_source.move_down(data.id);
dt.refresh();
}),
'Remove': RsvDataGrid.func_action(function(dt, row, data) {
rsv_elements_source.remove(data.id);
dt.refresh();
}),
}),
'label': RsvDataGrid.column('Label', false),
'type': RsvDataGrid.column('Type', false),
'desc': RsvDataGrid.column('Description', false),
'required': RsvDataGrid.column('Required', false),
'details': RsvDataGrid.column('Details', false),
}
);
elements_dt.map_column('details', (dt, row, data) => {
const td = document.createElement('td');
if (data.type === 'reservation') {
const parts = [];
if (data.timetable_id) {
const t = rsv_timetables.find(t => t.id === data.timetable_id);
parts.push(`Timetable: ${t ? t.name : data.timetable_id}`);
}
if (data.price_per_block != null) parts.push(`Price/block: ${data.price_per_block}`);
const et = data.email_templates ?? {};
const emails = [];
if (et.on_accepted?.enabled) emails.push('accepted');
if (et.on_refused?.enabled) emails.push('refused');
if (emails.length) parts.push(`Emails: ${emails.join(', ')}`);
td.innerText = parts.join(' · ');
}
return td;
});
elements_dt.refresh();
document.getElementById('rsv_add_element_btn').onclick = function() {
rsv_elements_source.add();
elements_dt.refresh();
};
}
RsvAdminForm.bind(document.getElementById('<?= $form_id ?>'), {
transform: (body) => ({
name: body.name,
definition: {
email_key: body.definition?.email_key ?? '',
elements: rsv_elements_source.get_all(),
},
}),
refresh: () => { if (typeof forms_dt !== 'undefined') forms_dt.refresh(); },
});
</script>
<?php
}
function rsv_form_info_page(): void {
global $rsv_form_registry;
$element_types = array_keys($rsv_form_registry->handlers);
$elements_with_ids = [];
$next_id = 1;
$timetables = (new RsvTimetableService())->get_all();
?>
<h1>Formuláře</h1>
<hr>
<div id="col-container" class="wp-clearfix">
<div id="col-left">
<div class="col-wrap">
<div class="form-wrap">
<h2>Přidat formulář</h2>
<form id="add_form_definition"
method="post"
data-method="POST"
data-success-msg="Form definition created."
action="<?= get_rest_url(null, 'reservations/v1/form-definition'); ?>">
<?php wp_nonce_field('my_action', 'my_nonce'); ?>
<?php echo RsvFormBuilder::create('add_form_definition')->text('name', 'Název')->render(); ?>
</form>
</div>
private function show_list(): void {
global $rsv_form_registry;
$element_types = array_keys($rsv_form_registry->handlers);
$elements_with_ids = [];
$next_id = 1;
$timetables = (new RsvTimetableService())->get_all();
?>
<h1>Formuláře</h1>
<hr>
<?php
RsvColumnLayout::split('1:2')
->column(function () {
echo RsvFormBuilder::create('add_form_definition', get_rest_url(null, 'reservations/v1/form-definition'), 'POST', 'Form definition created.')
->heading('Přidat formulář')
->nonce('my_action', 'my_nonce')
->text('name', 'Název')
->render();
?>
<hr>
<p class="submit">
<button type="submit" form="add_form_definition" class="button button-primary">Add Form Definition</button>
</p>
</div>
</div>
<div id="col-right">
<div class="col-wrap">
<?php })
->column(function () { ?>
<div id="forms_table"></div>
<script>
var forms_dt = RsvDataGrid.create_data_grid(forms_table,
@@ -289,6 +47,7 @@ function rsv_form_info_page(): void {
`<?= menu_page_url('forms-settings', false) ?>&id=${data.form_id}&action=edit`
),
'Trash': RsvDataGrid.func_action(function(dt, row, data) {
if (!confirm('Delete this form? This cannot be undone.')) return;
dt.resource.delete(data.form_id).then(() => forms_dt.refresh()).catch(err => alert(err.message));
}),
'Clone': RsvDataGrid.func_action(function(dt, row, data) {
@@ -318,80 +77,325 @@ function rsv_form_info_page(): void {
});
forms_dt.refresh();
</script>
</div>
</div>
</div>
<?php })
->output();
?>
<?php rsv_elements_table_script($elements_with_ids, $next_id, 'add_form_definition', $element_types, $timetables); ?>
<?php
}
function rsv_form_definition_edit_page(int $id): void {
global $rsv_form_registry;
$element_types = array_keys($rsv_form_registry->handlers);
$repo = new RsvFormDefinitionRepository();
$form_def = $repo->get($id);
if ($form_def === null) {
echo '<div class="notice notice-error"><p>Form definition not found.</p></div>';
return;
<?php $this->elements_table_script($elements_with_ids, $next_id, 'add_form_definition', $element_types, $timetables); ?>
<?php
}
$definition = $form_def['definition'] ?? [];
$raw_elements = array_values($definition['elements'] ?? []);
private function show_edit(int $id): void {
global $rsv_form_registry;
$element_types = array_keys($rsv_form_registry->handlers);
$elements_with_ids = array_map(function (array $el, int $idx): array {
return array_merge($el, ['id' => $idx + 1]);
}, $raw_elements, array_keys($raw_elements));
$repo = new RsvFormDefinitionRepository();
$form_def = $repo->get($id);
$next_id = count($elements_with_ids) + 1;
$timetables = (new RsvTimetableService())->get_all();
?>
<h1>Edit Form: <?= esc_html($form_def['name']) ?></h1>
<a href="<?= menu_page_url('forms-settings', false) ?>">← Back to Forms</a>
<hr>
<form id="edit_form_definition"
method="post"
data-method="PUT"
data-success-msg="Form definition updated."
action="<?= get_rest_url(null, 'reservations/v1/form-definition/' . $id); ?>">
<?php
echo RsvFormBuilder::create('edit_form_definition')
->text('name', 'Name', '', true, $form_def['name'])
->text('definition.email_key', 'Email Key', "Name of the form field that holds the submitter's email address.", true, $definition['email_key'] ?? '')
->render();
?>
</form>
<hr>
<h2>Form Elements</h2>
<p>Define the fields that will appear in this form.</p>
<div id="form_elements_table"></div>
<p>
<button type="button" class="button" id="rsv_add_element_btn">+ Add Element</button>
</p>
<p class="submit">
<button type="submit" form="edit_form_definition" class="button button-primary">Update Form Definition</button>
</p>
<?php rsv_elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables); ?>
<?php
}
function rsv_forms_page(): void {
if (isset($_GET['action'])) {
if ($_GET['action'] === 'edit' && isset($_GET['id'])) {
rsv_form_definition_edit_page(intval($_GET['id']));
if ($form_def === null) {
echo '<div class="notice notice-error"><p>Form definition not found.</p></div>';
return;
}
$definition = $form_def['definition'] ?? [];
$raw_elements = array_values($definition['elements'] ?? []);
$elements_with_ids = array_map(function (array $el, int $idx): array {
return array_merge($el, ['id' => $idx + 1]);
}, $raw_elements, array_keys($raw_elements));
$next_id = count($elements_with_ids) + 1;
$timetables = (new RsvTimetableService())->get_all();
$email_key_options = ['' => '— select field —'];
foreach ($raw_elements as $el) {
$el_name = $el['name'] ?? '';
if ($el_name === '') {
continue;
}
$el_label = $el['label'] ?? '';
$email_key_options[$el_name] = $el_label !== '' ? "$el_label ($el_name)" : $el_name;
}
?>
<h1>Edit Form: <?= esc_html($form_def['name']) ?></h1>
<a href="<?= menu_page_url('forms-settings', false) ?>">← Back to Forms</a>
<hr>
<?php
echo RsvFormBuilder::create('edit_form_definition', get_rest_url(null, 'reservations/v1/form-definition/' . $id), 'PUT', 'Form definition updated.')
->text('name', 'Name', '', true, $form_def['name'])
->select('definition.email_key', 'Email Key', $email_key_options, "Form field that holds the submitter's email address.", true, $definition['email_key'] ?? '')
->render();
?>
<hr>
<h2>Form Elements</h2>
<p>Define the fields that will appear in this form.</p>
<div id="form_elements_table"></div>
<p>
<button type="button" class="button" id="rsv_add_element_btn">+ Add Element</button>
</p>
<p class="submit">
<button type="submit" form="edit_form_definition" class="button button-primary">Update Form Definition</button>
</p>
<?php $this->elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables); ?>
<?php
}
rsv_form_info_page();
private function elements_table_script(array $elements_with_ids, int $next_id, string $form_id, array $element_types, array $timetables = []): void {
$elements_json = json_encode($elements_with_ids);
$types_json = json_encode(array_values($element_types));
$timetables_json = json_encode(array_values($timetables));
?>
<script>
const rsv_element_types = <?= $types_json ?>;
const rsv_timetables = <?= $timetables_json ?>;
const RSV_EMAIL_DEFAULTS = {
accepted_subject: <?= json_encode('Rezervace přijata') ?>,
accepted_body: <?= json_encode("<h1>Vaše rezervace byla přijata</h1>\n<p>Vaše rezervace byla schválena. Těšíme se na vás!</p>") ?>,
refused_subject: <?= json_encode('Rezervace zamítnuta') ?>,
refused_body: <?= json_encode("<h1>Vaše rezervace byla zamítnuta</h1>\n<p>Vaše rezervace bohužel nebyla schválena. Zkuste prosím jiný termín.</p>") ?>,
};
const rsv_elements_source = (function(initial_items, next_id_start) {
const items = initial_items;
let next_id = next_id_start;
return {
get_page(skip, limit) {
skip = skip ?? 0;
limit = limit ?? 20;
return Promise.resolve({ total: items.length, data: items.slice(skip, skip + limit) });
},
put(id, data) {
const idx = items.findIndex(e => e.id === id);
if (idx === -1) return Promise.reject(new Error('Element not found'));
// Destructure reservation-specific fields so they don't bleed into extra_attrs.
const { id: _id, name: _n, label: _l, type: _t, desc: _d, required: _r,
price_per_block: _p, email_templates: _et, timetable_id: _ti, ...extra_attrs } = items[idx];
items[idx] = {
...extra_attrs,
id,
name: data.name ?? '',
label: data.label ?? '',
type: data.type ?? 'text',
desc: data.desc ?? '',
required: data.required === 'on',
...(data.type === 'input-text' ? {
validation: data.validation ?? '',
pattern: data.pattern ?? '',
pattern_message: data.pattern_message ?? '',
} : {}),
...(data.type === 'reservation' ? {
timetable_id: data.timetable_id ? parseInt(data.timetable_id) : null,
price_per_block: parseFloat(data.price_per_block ?? '0') || 0,
email_templates: {
on_accepted: {
enabled: !!data.email_accepted_enabled,
subject: data.email_accepted_subject ?? RSV_EMAIL_DEFAULTS.accepted_subject,
body: data.email_accepted_body ?? RSV_EMAIL_DEFAULTS.accepted_body,
},
on_refused: {
enabled: !!data.email_refused_enabled,
subject: data.email_refused_subject ?? RSV_EMAIL_DEFAULTS.refused_subject,
body: data.email_refused_body ?? RSV_EMAIL_DEFAULTS.refused_body,
},
},
} : {}),
};
return Promise.resolve(items[idx]);
},
add() {
const item = { id: next_id++, name: '', label: '', type: 'text', desc: '', required: false };
items.push(item);
return item;
},
move_up(id) {
const idx = items.findIndex(e => e.id === id);
if (idx > 0) [items[idx - 1], items[idx]] = [items[idx], items[idx - 1]];
},
move_down(id) {
const idx = items.findIndex(e => e.id === id);
if (idx !== -1 && idx < items.length - 1) [items[idx], items[idx + 1]] = [items[idx + 1], items[idx]];
},
remove(id) {
const idx = items.findIndex(e => e.id === id);
if (idx !== -1) items.splice(idx, 1);
},
get_all() {
return items.map(({ id, ...rest }) => rest);
},
};
})(<?= $elements_json ?>, <?= $next_id ?>);
function rsv_render_element_inline_form(dt, row, data) {
const builder = RsvInlineFormBuilder.create(rsv_elements_source)
.fieldset('Element', '50%')
.input_text('name', 'Slug', data?.name ?? '')
.input_text('label', 'Label', data?.label ?? '')
.input_select('type', 'Type', rsv_element_types, data?.type ?? rsv_element_types[0])
.fieldset('Options', '50%')
.input_text('desc', 'Description', data?.desc ?? '')
.input_checkbox('required', 'Required', data?.required ?? false);
if ((data?.type ?? rsv_element_types[0]) === 'reservation') {
const accepted = data?.email_templates?.on_accepted ?? {};
const refused = data?.email_templates?.on_refused ?? {};
const timetable_options = [
{ value: '', label: '— none —' },
...rsv_timetables.map(t => ({ value: t.id, label: t.name })),
];
builder
.input_select('timetable_id', 'Timetable', timetable_options, data?.timetable_id ?? '')
.input_number('price_per_block', 'Price per block', data?.price_per_block ?? 0)
.fieldset('Email — accepted', '100%')
.input_checkbox('email_accepted_enabled', 'Send email when accepted', accepted.enabled ?? true)
.input_text('email_accepted_subject', 'Subject', accepted.subject ?? RSV_EMAIL_DEFAULTS.accepted_subject)
.input_textarea('email_accepted_body', 'Body', accepted.body ?? RSV_EMAIL_DEFAULTS.accepted_body)
.fieldset('Email — refused', '100%')
.input_checkbox('email_refused_enabled', 'Send email when refused', refused.enabled ?? true)
.input_text('email_refused_subject', 'Subject', refused.subject ?? RSV_EMAIL_DEFAULTS.refused_subject)
.input_textarea('email_refused_body', 'Body', refused.body ?? RSV_EMAIL_DEFAULTS.refused_body);
}
if ((data?.type ?? rsv_element_types[0]) === 'input-text') {
builder
.input_select('validation', 'Validation', [
{ value: '', label: '— none —' },
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone' },
{ value: 'digits', label: 'Digits only' },
{ value: 'pattern', label: 'Custom pattern' },
], data?.validation ?? '')
.input_text('pattern', 'Custom pattern (regex)', data?.pattern ?? '')
.show_if(RsvInlineFormBuilder.match_p('validation', 'pattern'))
.input_text('pattern_message', 'Pattern error message', data?.pattern_message ?? '')
.show_if(RsvInlineFormBuilder.match_p('validation', 'pattern'));
}
const node = builder.build({
id: data?.id,
colspan: 6,
save_label: 'Save',
on_success: () => elements_dt.refresh(),
on_cancel: () => elements_dt.refresh(),
});
// Type swaps whole fieldsets, so re-render the inline form on change.
// Common fields are carried over; type-specific fields reset to stored data.
const type_select = node.querySelector('select[name="type"]');
if (type_select) {
type_select.addEventListener('change', () => {
const current = Object.fromEntries(new FormData(node.querySelector('form')));
const merged = {
...data,
name: current.name ?? data?.name,
label: current.label ?? data?.label,
desc: current.desc ?? data?.desc,
required: 'required' in current,
type: type_select.value,
};
row.replaceChildren(rsv_render_element_inline_form(dt, row, merged));
});
}
return node;
}
// The elements data grid only exists on the edit page. Guard it so a missing
// container can't abort the script and strip the form's submit handler.
const elements_table_el = document.getElementById('form_elements_table');
if (elements_table_el) {
var elements_dt = RsvDataGrid.create_data_grid(
elements_table_el,
rsv_elements_source,
{
'name': RsvDataGrid.action_column('Name', false, {
'Edit': RsvDataGrid.edit_action(function(dt, row, data) {
row.classList.add(
'inline-edit-row', 'inline-edit-row-post',
'quick-edit-row', 'quick-edit-row-post',
'inline-edit-post', 'inline-editor'
);
row.replaceChildren(rsv_render_element_inline_form(dt, row, data));
}),
'Move Up': RsvDataGrid.func_action(function(dt, row, data) {
rsv_elements_source.move_up(data.id);
dt.refresh();
}),
'Move Down': RsvDataGrid.func_action(function(dt, row, data) {
rsv_elements_source.move_down(data.id);
dt.refresh();
}),
'Remove': RsvDataGrid.func_action(function(dt, row, data) {
rsv_elements_source.remove(data.id);
dt.refresh();
}),
}),
'label': RsvDataGrid.column('Label', false),
'type': RsvDataGrid.column('Type', false),
'desc': RsvDataGrid.column('Description', false),
'required': RsvDataGrid.column('Required', false),
'details': RsvDataGrid.column('Details', false),
}
);
elements_dt.map_column('details', (dt, row, data) => {
const td = document.createElement('td');
if (data.type === 'reservation') {
const parts = [];
if (data.timetable_id) {
const t = rsv_timetables.find(t => t.id === data.timetable_id);
parts.push(`Timetable: ${t ? t.name : data.timetable_id}`);
}
if (data.price_per_block != null) parts.push(`Price/block: ${data.price_per_block}`);
const et = data.email_templates ?? {};
const emails = [];
if (et.on_accepted?.enabled) emails.push('accepted');
if (et.on_refused?.enabled) emails.push('refused');
if (emails.length) parts.push(`Emails: ${emails.join(', ')}`);
td.innerText = parts.join(' · ');
}
return td;
});
elements_dt.refresh();
document.getElementById('rsv_add_element_btn').onclick = function() {
rsv_elements_source.add();
elements_dt.refresh();
};
}
// The Email Key select offers the form's fields. Elements are edited in
// the grid above and only persisted on save, so refresh the options from
// the live source each time the select is opened.
const rsv_email_key_select = document.querySelector('#edit_form_definition select[name="definition.email_key"]');
function rsv_sync_email_key_options() {
if (!rsv_email_key_select) return;
const selected = rsv_email_key_select.value;
const options = [new Option('— select field —', '')];
for (const el of rsv_elements_source.get_all()) {
if (!el.name) continue;
options.push(new Option(el.label ? `${el.label} (${el.name})` : el.name, el.name));
}
rsv_email_key_select.replaceChildren(...options);
rsv_email_key_select.value = selected;
}
rsv_email_key_select?.addEventListener('focus', rsv_sync_email_key_options);
RsvAdminForm.bind(document.getElementById('<?= $form_id ?>'), {
transform: (body) => ({
name: body.name,
definition: {
email_key: body.definition?.email_key ?? '',
elements: rsv_elements_source.get_all(),
},
}),
refresh: () => { if (typeof forms_dt !== 'undefined') forms_dt.refresh(); },
});
</script>
<?php
}
}
@@ -1,56 +1,53 @@
<?php
function rsv_google_calendar_settings_page(): void {
if (!current_user_can(RsvCapabilities::MANAGE)) {
return;
}
class RsvGoogleCalendarSettingsPage extends RsvAdminPage {
$service = new RsvGoogleCalendarService();
$notice = null;
protected function render_content(): void {
$service = new RsvGoogleCalendarService();
$notice = null;
if (isset($_GET['connected'])) {
$notice = ['type' => 'success', 'message' => 'Google Calendar connected successfully.'];
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['rsv_google_settings_nonce'])) {
if (!wp_verify_nonce($_POST['rsv_google_settings_nonce'], 'rsv_google_settings')) {
wp_die('Security check failed.');
if (isset($_GET['connected'])) {
$notice = ['type' => 'success', 'message' => 'Google Calendar connected successfully.'];
}
if (isset($_POST['rsv_disconnect'])) {
$service->disconnect();
$notice = ['type' => 'success', 'message' => 'Disconnected from Google Calendar.'];
} elseif (isset($_POST['rsv_register_webhook'])) {
$result = $service->register_webhook();
$notice = isset($result['id'])
? ['type' => 'success', 'message' => 'Webhook registered.']
: ['type' => 'error', 'message' => 'Webhook registration failed: ' . ($result['error'] ?? json_encode($result))];
} elseif (isset($_POST['rsv_stop_webhook'])) {
$service->stop_webhook();
$notice = ['type' => 'success', 'message' => 'Webhook stopped.'];
} else {
update_option('rsv_google_client_id', sanitize_text_field($_POST['rsv_google_client_id'] ?? ''));
update_option('rsv_google_calendar_id', sanitize_text_field($_POST['rsv_google_calendar_id'] ?? 'primary'));
// Only overwrite the (encrypted) client secret when a new value is
// supplied — the field renders blank, so otherwise saving any other
// setting would wipe the stored secret.
$client_secret = sanitize_text_field($_POST['rsv_google_client_secret'] ?? '');
if ($client_secret !== '') {
$service->set_client_secret($client_secret);
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['rsv_google_settings_nonce'])) {
if (!wp_verify_nonce($_POST['rsv_google_settings_nonce'], 'rsv_google_settings')) {
wp_die('Security check failed.');
}
$notice = ['type' => 'success', 'message' => 'Settings saved.'];
}
}
$connected = $service->is_google_connected();
$webhook_registered = $service->is_webhook_registered();
$webhook_expiry = (int) get_option('rsv_google_webhook_expiration', 0);
$client_id = esc_attr(get_option('rsv_google_client_id', ''));
$cal_id = esc_attr(get_option('rsv_google_calendar_id', 'primary'));
$oauth_url = esc_url($service->get_oauth_url());
?>
<div class="wrap">
if (isset($_POST['rsv_disconnect'])) {
$service->disconnect();
$notice = ['type' => 'success', 'message' => 'Disconnected from Google Calendar.'];
} elseif (isset($_POST['rsv_register_webhook'])) {
$result = $service->register_webhook();
$notice = isset($result['id'])
? ['type' => 'success', 'message' => 'Webhook registered.']
: ['type' => 'error', 'message' => 'Webhook registration failed: ' . ($result['error'] ?? json_encode($result))];
} elseif (isset($_POST['rsv_stop_webhook'])) {
$service->stop_webhook();
$notice = ['type' => 'success', 'message' => 'Webhook stopped.'];
} else {
update_option('rsv_google_client_id', sanitize_text_field($_POST['rsv_google_client_id'] ?? ''));
update_option('rsv_google_calendar_id', sanitize_text_field($_POST['rsv_google_calendar_id'] ?? 'primary'));
// Only overwrite the (encrypted) client secret when a new value is
// supplied — the field renders blank, so otherwise saving any other
// setting would wipe the stored secret.
$client_secret = sanitize_text_field($_POST['rsv_google_client_secret'] ?? '');
if ($client_secret !== '') {
$service->set_client_secret($client_secret);
}
$notice = ['type' => 'success', 'message' => 'Settings saved.'];
}
}
$connected = $service->is_google_connected();
$webhook_registered = $service->is_webhook_registered();
$webhook_expiry = (int) get_option('rsv_google_webhook_expiration', 0);
$client_id = esc_attr(get_option('rsv_google_client_id', ''));
$cal_id = esc_attr(get_option('rsv_google_calendar_id', 'primary'));
$oauth_url = esc_url($service->get_oauth_url());
?>
<h1>Google Calendar</h1>
<?php if ($notice): ?>
@@ -127,6 +124,6 @@ function rsv_google_calendar_settings_page(): void {
<?php endif; ?>
<?php endif; ?>
</form>
</div>
<?php
<?php
}
}
+206 -203
View File
@@ -1,219 +1,222 @@
<?php
function rsv_reservations_page(): void {
?>
<h1>Form Submissions</h1>
class RsvReservationsPage extends RsvAdminPage {
<hr>
<div id="reservations_table"></div>
protected function render_content(): void {
?>
<h1>Form Submissions</h1>
<script>
function rsv_fmt_utc(utc_str) {
if (!utc_str) return '';
return new Date(utc_str.replace(' ', 'T') + 'Z').toLocaleString();
}
<hr>
<div id="reservations_table"></div>
function rsv_cell_value(value) {
if (value === null || value === undefined) return '';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
function rsv_make_table(head_labels, rows_data, cell_fn) {
const table = document.createElement('table');
table.classList.add('wp-list-table', 'widefat', 'fixed', 'striped', 'rsv-detail-table');
const thead = document.createElement('thead');
const header_row = document.createElement('tr');
for (const label of head_labels) {
const th = document.createElement('th');
th.textContent = label;
header_row.appendChild(th);
<script>
function rsv_fmt_utc(utc_str) {
if (!utc_str) return '';
return new Date(utc_str.replace(' ', 'T') + 'Z').toLocaleString();
}
thead.appendChild(header_row);
table.appendChild(thead);
const tbody = document.createElement('tbody');
for (const row_data of rows_data) {
const tr = document.createElement('tr');
for (const cell of cell_fn(row_data)) {
const td = document.createElement('td');
td.textContent = cell;
tr.appendChild(td);
function rsv_cell_value(value) {
if (value === null || value === undefined) return '';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
function rsv_make_table(head_labels, rows_data, cell_fn) {
const table = document.createElement('table');
table.classList.add('wp-list-table', 'widefat', 'fixed', 'striped', 'rsv-detail-table');
const thead = document.createElement('thead');
const header_row = document.createElement('tr');
for (const label of head_labels) {
const th = document.createElement('th');
th.textContent = label;
header_row.appendChild(th);
}
tbody.appendChild(tr);
}
table.appendChild(tbody);
thead.appendChild(header_row);
table.appendChild(thead);
return table;
}
function rsv_flatten_form_entries(obj, depth) {
const rows = [];
for (const [key, val] of Object.entries(obj)) {
if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
rows.push({ key, value: null, depth });
for (const child of rsv_flatten_form_entries(val, depth + 1)) rows.push(child);
} else if (Array.isArray(val)) {
rows.push({ key, value: null, depth });
val.forEach((item, i) => {
if (item !== null && typeof item === 'object') {
rows.push({ key: `[${i}]`, value: null, depth: depth + 1 });
for (const child of rsv_flatten_form_entries(item, depth + 2)) rows.push(child);
} else {
rows.push({ key: `[${i}]`, value: rsv_cell_value(item), depth: depth + 1 });
}
});
} else {
rows.push({ key, value: rsv_cell_value(val), depth });
const tbody = document.createElement('tbody');
for (const row_data of rows_data) {
const tr = document.createElement('tr');
for (const cell of cell_fn(row_data)) {
const td = document.createElement('td');
td.textContent = cell;
tr.appendChild(td);
}
tbody.appendChild(tr);
}
}
return rows;
}
table.appendChild(tbody);
function rsv_make_form_table(form_values) {
const table = document.createElement('table');
table.classList.add('wp-list-table', 'widefat', 'fixed', 'striped', 'rsv-detail-table');
const thead = document.createElement('thead');
const header_row = document.createElement('tr');
for (const label of ['Field', 'Value']) {
const th = document.createElement('th');
th.textContent = label;
header_row.appendChild(th);
}
thead.appendChild(header_row);
table.appendChild(thead);
const tbody = document.createElement('tbody');
for (const { key, value, depth } of rsv_flatten_form_entries(form_values, 0)) {
const tr = document.createElement('tr');
const td_key = document.createElement('td');
td_key.textContent = key;
td_key.classList.add('rsv-form-key');
td_key.style.setProperty('--rsv-depth', depth);
if (value === null) td_key.classList.add('rsv-form-key--group');
const td_val = document.createElement('td');
td_val.textContent = value ?? '';
if (value === null) td_val.classList.add('rsv-form-val--null');
tr.appendChild(td_key);
tr.appendChild(td_val);
tbody.appendChild(tr);
}
table.appendChild(tbody);
return table;
}
function rsv_render_reservation_detail(dt, row, data, detail) {
const td = document.createElement('td');
td.setAttribute('colspan', 3);
td.classList.add('rsv-detail-expand');
const form_heading = document.createElement('h4');
form_heading.textContent = 'Form Submission';
form_heading.classList.add('rsv-detail-heading');
const form_values = detail.form_values ?? {};
let form_content;
if (Object.keys(form_values).length === 0) {
form_content = document.createElement('p');
form_content.textContent = 'No form values recorded.';
form_content.classList.add('rsv-detail-empty');
} else {
form_content = rsv_make_form_table(form_values);
return table;
}
const timetable_heading = document.createElement('h4');
timetable_heading.textContent = 'Timetable Reservations';
timetable_heading.classList.add('rsv-detail-heading');
const timetable_rows = detail.timetable_reservations ?? [];
let timetable_content;
if (timetable_rows.length === 0) {
timetable_content = document.createElement('p');
timetable_content.textContent = 'No timetable reservations.';
timetable_content.classList.add('rsv-detail-empty');
} else {
timetable_content = rsv_make_table(
['ID', 'Timetable', 'Start', 'End'],
timetable_rows,
r => [r.id, r.timetable_id, rsv_fmt_utc(r.start), rsv_fmt_utc(r.end)]
);
}
const actions = document.createElement('div');
actions.classList.add('rsv-detail-actions');
const close_btn = document.createElement('button');
close_btn.classList.add('button');
close_btn.textContent = 'Close';
close_btn.onclick = () => dt.refresh_row(row, data);
actions.appendChild(close_btn);
if (detail.pending_confirmation) {
const base_url = `<?= get_rest_url(null, 'reservations/v1/reservation'); ?>/${data.id}`;
const accept_btn = document.createElement('button');
accept_btn.classList.add('button', 'button-primary');
accept_btn.textContent = 'Accept';
accept_btn.onclick = () => {
accept_btn.disabled = true;
refuse_btn.disabled = true;
fetch(base_url + '/accept', { method: 'POST', credentials: 'same-origin' })
.then(() => dt.refresh())
.catch(() => { accept_btn.disabled = false; refuse_btn.disabled = false; });
};
const refuse_btn = document.createElement('button');
refuse_btn.classList.add('button', 'button-secondary', 'rsv-btn-refuse');
refuse_btn.textContent = 'Refuse';
refuse_btn.onclick = () => {
accept_btn.disabled = true;
refuse_btn.disabled = true;
fetch(base_url + '/refuse', { method: 'POST', credentials: 'same-origin' })
.then(() => dt.refresh())
.catch(() => { accept_btn.disabled = false; refuse_btn.disabled = false; });
};
actions.appendChild(accept_btn);
actions.appendChild(refuse_btn);
}
td.replaceChildren(form_heading, form_content, timetable_heading, timetable_content, actions);
return td;
}
var reservations_dt = RsvDataGrid.create_data_grid(
document.getElementById('reservations_table'),
RsvReservationResource(),
{
'id': RsvDataGrid.action_column('ID', false, {
'View': RsvDataGrid.func_action(function(dt, row, data) {
const url = `<?= get_rest_url(null, 'reservations/v1/reservation'); ?>/${data.id}`;
fetch(url, {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
})
.then(r => r.json())
.then(detail => {
row.classList.add(
'inline-edit-row', 'inline-edit-row-post',
'quick-edit-row', 'quick-edit-row-post',
'inline-edit-post', 'inline-editor'
);
row.replaceChildren(rsv_render_reservation_detail(dt, row, data, detail));
function rsv_flatten_form_entries(obj, depth) {
const rows = [];
for (const [key, val] of Object.entries(obj)) {
if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
rows.push({ key, value: null, depth });
for (const child of rsv_flatten_form_entries(val, depth + 1)) rows.push(child);
} else if (Array.isArray(val)) {
rows.push({ key, value: null, depth });
val.forEach((item, i) => {
if (item !== null && typeof item === 'object') {
rows.push({ key: `[${i}]`, value: null, depth: depth + 1 });
for (const child of rsv_flatten_form_entries(item, depth + 2)) rows.push(child);
} else {
rows.push({ key: `[${i}]`, value: rsv_cell_value(item), depth: depth + 1 });
}
});
}),
}),
'form_submit_id': RsvDataGrid.column('Form Submit', false),
'is_confirmed': RsvDataGrid.column('Confirmed', false),
} else {
rows.push({ key, value: rsv_cell_value(val), depth });
}
}
return rows;
}
);
reservations_dt.refresh();
</script>
<?php
function rsv_make_form_table(form_values) {
const table = document.createElement('table');
table.classList.add('wp-list-table', 'widefat', 'fixed', 'striped', 'rsv-detail-table');
const thead = document.createElement('thead');
const header_row = document.createElement('tr');
for (const label of ['Field', 'Value']) {
const th = document.createElement('th');
th.textContent = label;
header_row.appendChild(th);
}
thead.appendChild(header_row);
table.appendChild(thead);
const tbody = document.createElement('tbody');
for (const { key, value, depth } of rsv_flatten_form_entries(form_values, 0)) {
const tr = document.createElement('tr');
const td_key = document.createElement('td');
td_key.textContent = key;
td_key.classList.add('rsv-form-key');
td_key.style.setProperty('--rsv-depth', depth);
if (value === null) td_key.classList.add('rsv-form-key--group');
const td_val = document.createElement('td');
td_val.textContent = value ?? '';
if (value === null) td_val.classList.add('rsv-form-val--null');
tr.appendChild(td_key);
tr.appendChild(td_val);
tbody.appendChild(tr);
}
table.appendChild(tbody);
return table;
}
function rsv_render_reservation_detail(dt, row, data, detail) {
const td = document.createElement('td');
td.setAttribute('colspan', 3);
td.classList.add('rsv-detail-expand');
const form_heading = document.createElement('h4');
form_heading.textContent = 'Form Submission';
form_heading.classList.add('rsv-detail-heading');
const form_values = detail.form_values ?? {};
let form_content;
if (Object.keys(form_values).length === 0) {
form_content = document.createElement('p');
form_content.textContent = 'No form values recorded.';
form_content.classList.add('rsv-detail-empty');
} else {
form_content = rsv_make_form_table(form_values);
}
const timetable_heading = document.createElement('h4');
timetable_heading.textContent = 'Timetable Reservations';
timetable_heading.classList.add('rsv-detail-heading');
const timetable_rows = detail.timetable_reservations ?? [];
let timetable_content;
if (timetable_rows.length === 0) {
timetable_content = document.createElement('p');
timetable_content.textContent = 'No timetable reservations.';
timetable_content.classList.add('rsv-detail-empty');
} else {
timetable_content = rsv_make_table(
['ID', 'Timetable', 'Start', 'End'],
timetable_rows,
r => [r.id, r.timetable_id, rsv_fmt_utc(r.start), rsv_fmt_utc(r.end)]
);
}
const actions = document.createElement('div');
actions.classList.add('rsv-detail-actions');
const close_btn = document.createElement('button');
close_btn.classList.add('button');
close_btn.textContent = 'Close';
close_btn.onclick = () => dt.refresh_row(row, data);
actions.appendChild(close_btn);
if (detail.pending_confirmation) {
const base_url = `<?= get_rest_url(null, 'reservations/v1/reservation'); ?>/${data.id}`;
const accept_btn = document.createElement('button');
accept_btn.classList.add('button', 'button-primary');
accept_btn.textContent = 'Accept';
accept_btn.onclick = () => {
accept_btn.disabled = true;
refuse_btn.disabled = true;
fetch(base_url + '/accept', { method: 'POST', credentials: 'same-origin' })
.then(() => dt.refresh())
.catch(() => { accept_btn.disabled = false; refuse_btn.disabled = false; });
};
const refuse_btn = document.createElement('button');
refuse_btn.classList.add('button', 'button-secondary', 'rsv-btn-refuse');
refuse_btn.textContent = 'Refuse';
refuse_btn.onclick = () => {
accept_btn.disabled = true;
refuse_btn.disabled = true;
fetch(base_url + '/refuse', { method: 'POST', credentials: 'same-origin' })
.then(() => dt.refresh())
.catch(() => { accept_btn.disabled = false; refuse_btn.disabled = false; });
};
actions.appendChild(accept_btn);
actions.appendChild(refuse_btn);
}
td.replaceChildren(form_heading, form_content, timetable_heading, timetable_content, actions);
return td;
}
var reservations_dt = RsvDataGrid.create_data_grid(
document.getElementById('reservations_table'),
RsvReservationResource(),
{
'id': RsvDataGrid.action_column('ID', false, {
'View': RsvDataGrid.func_action(function(dt, row, data) {
const url = `<?= get_rest_url(null, 'reservations/v1/reservation'); ?>/${data.id}`;
fetch(url, {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
})
.then(r => r.json())
.then(detail => {
row.classList.add(
'inline-edit-row', 'inline-edit-row-post',
'quick-edit-row', 'quick-edit-row-post',
'inline-edit-post', 'inline-editor'
);
row.replaceChildren(rsv_render_reservation_detail(dt, row, data, detail));
});
}),
}),
'form_submit_id': RsvDataGrid.column('Form Submit', false),
'is_confirmed': RsvDataGrid.column('Confirmed', false),
}
);
reservations_dt.refresh();
</script>
<?php
}
}
+315 -375
View File
@@ -1,71 +1,59 @@
<?php
use Reservair\Forms\RsvFormBuilder;
use Reservair\Layout\RsvColumnLayout;
class RsvTimetablePage extends RsvAdminPage {
function rsv_timetable_list_page() {
?>
<style>
/*#col-left {
width: 30%;
}*/
protected function render_content(): void {
if (isset($_GET['action'])) {
if ($_GET['action'] === 'view' && isset($_GET['id'])) {
$this->show_view(intval($_GET['id']));
return;
} elseif ($_GET['action'] === 'edit' && isset($_GET['id'])) {
$this->show_capacity(intval($_GET['id']));
return;
}
// Deletion is intentionally not handled here: a state-changing GET is
// CSRF-prone. Timetables are deleted via the nonce-authenticated REST
// DELETE /timetable/{id} (see the "Trash" action in the list grid).
}
/*#col-right {
width: 70%;
}*/
</style>
<h1>Timetables</h1>
<hr>
<div id="col-container" class="wp-clearfix">
<div id="col-left">
<div class="col-wrap">
<div class="form-wrap">
<h2>Add timetable</h2>
$this->show_list();
}
<?php
$timetable_service = new RsvTimetableService();
$existing_emails = $timetable_service->get_all_maintainer_emails();
$existing_emails_json = json_encode($existing_emails);
?>
<datalist id="maintainer_email_suggestions">
<?php foreach ($existing_emails as $email): ?>
<option value="<?= esc_attr($email) ?>">
<?php endforeach; ?>
</datalist>
private function show_list(): void {
?>
<h1>Timetables</h1>
<hr>
<?php
$timetable_service = new RsvTimetableService();
$existing_emails = $timetable_service->get_all_maintainer_emails();
<form id="add_timetable_form"
method="post"
data-method="POST"
data-success-msg="Timetable created."
action="<?= get_rest_url(null, 'reservations/v1/timetable'); ?>">
<?php
echo RsvFormBuilder::create('add_timetable_form')
->text('name', 'Name', 'Name of the timetable that can be reserved.', true)
->number('block_size', 'Block length (minutes)', 'Duration of one reservable time block in minutes.', true, '', 1)
->email('maintainer_email', 'Maintainer Email', 'Email address to notify when a reservation requires confirmation.', false, '', 'maintainer_email_suggestions')
->submit('Add Timetable')
->render();
?>
</form>
<script>
RsvAdminForm.bind(add_timetable_form, {
transform: (body) => ({
name: body.name,
block_size: parseInt(body.block_size),
maintainer_email: body.maintainer_email || null,
}),
refresh: () => availability_dt.refresh(),
});
</script>
</div>
</div>
</div>
<div id="col-right">
<div class="col-wrap">
<div id="availability_table">
</div>
RsvColumnLayout::split('1:2')
->column(function () use ($existing_emails) {
echo RsvFormBuilder::create('add_timetable_form', get_rest_url(null, 'reservations/v1/timetable'), 'POST', 'Timetable created.')
->heading('Add timetable')
->datalist('maintainer_email_suggestions', $existing_emails)
->text('name', 'Name', 'Name of the timetable that can be reserved.', true)
->number('block_size', 'Block length (minutes)', 'Duration of one reservable time block in minutes.', true, '', 1)
->email('maintainer_email', 'Maintainer Email', 'Email address to notify when a reservation requires confirmation.', false, '', 'maintainer_email_suggestions')
->submit('Add Timetable')
->render();
?>
<script>
RsvAdminForm.bind(add_timetable_form, {
transform: (body) => ({
name: body.name,
block_size: parseInt(body.block_size),
maintainer_email: body.maintainer_email || null,
}),
refresh: () => availability_dt.refresh(),
});
</script>
<?php })
->column(function () { ?>
<div id="availability_table"></div>
<script>
var availability_dt = RsvDataGrid.create_data_grid(availability_table,
RsvTimetableResource(), {
@@ -83,94 +71,79 @@ function rsv_timetable_list_page() {
});
availability_dt.refresh();
</script>
</div>
</div>
</div>
<?php
}
<?php })
->output();
?>
<?php
}
function rsv_create_capacity_form($timetable_id) {
$form = RsvFormBuilder::create("create_capacity_form", get_rest_url(null, 'reservations/v1/timetable/' . $timetable_id . '/capacity'));
$form->date('date', 'First Date', 'Od kterého datumu platí tato kapacita.', true, new DateTime()->format('Y-m-d'));
$form->group('Availability Range', fn($g) => $g
->time('start_time', 'Start')
->time('end_time', 'End')
);
$form->number('capacity', 'Capacity', 'How many reservations can overlap on the same time.', true, 1, 1);
$form->number('min_lead_time_minutes', 'Minimum lead time (minutes)', 'How many minutes in advance must be the reservation created. This is useful if it takes some time to prepare the reservation.', true);
$form->checkbox('requires_confirmation', 'Requires Confirmation?', 'If checked, all the reservations that overlap this capacity will require confirmation from maintainer. The maintainer will receive an email asking for the confirmation.', true);
$form->custom('Is Repeating Event', function() {
return '
<input id="is_repeating" class="regular-text" type="checkbox" name="is_repeating" checked="true">
<p>If the capacity is available repeatingly. For example: repeat each monday every week.</p>
';
});
$form->number('repeat_period_in_days', 'Repeat Period (days)', 'How many days between each repetition.', true);
$form->custom('Apply to Days', function() {
return '
<table class="option-table">
<tbody>
<tr class="form-day-names">
<td>Monday</td>
<td>Tuesday</td>
<td>Wednesday</td>
<td>Thursday</td>
<td>Friday</td>
<td>Saturday</td>
<td>Sunday</td>
</tr>
<tr class="form-days">
<td><input class="is-repeating-input" type="checkbox" name="monday"></td>
<td><input class="is-repeating-input" type="checkbox" name="tuesday"></td>
<td><input class="is-repeating-input" type="checkbox" name="wednesday"></td>
<td><input class="is-repeating-input" type="checkbox" name="thursday"></td>
<td><input class="is-repeating-input" type="checkbox" name="friday"></td>
<td><input class="is-repeating-input" type="checkbox" name="saturday"></td>
<td><input class="is-repeating-input" type="checkbox" name="sunday"></td>
</tr>
</tbody>
</table>';
});
$form->submit('Create Capacity', 'button-primary', 'submit');
private function show_view(int $id): void {
$timetable = (new RsvTimetableService())->get($id);
if ($timetable === null) {
echo '<div class="notice notice-error"><p>Timetable not found.</p></div>';
return;
}
?>
<h1><?= esc_html($timetable->name) ?></h1>
<a href="<?= esc_url(menu_page_url('timetable-settings', false)) ?>"> Back to Timetables</a>
<hr>
?>
<form
id="create_capacity_form"
data-method="POST"
data-success-msg="Capacity created."
action="<?= get_rest_url(null, 'reservations/v1/timetable/' . $timetable_id . '/capacity'); ?>" >
<?php
$form->output();
<table class="form-table">
<tr><th>Name</th><td><?= esc_html($timetable->name) ?></td></tr>
<tr><th>Block size</th><td><?= esc_html($timetable->block_size) ?> minutes</td></tr>
<?php if ($timetable->maintainer_email): ?>
<tr><th>Maintainer email</th><td><?= esc_html($timetable->maintainer_email) ?></td></tr>
<?php endif; ?>
</table>
?>
</form>
<?php
}
<h2>Reservations</h2>
<div id="timetable_reservations_table"></div>
<script>
RsvDataGrid.create_data_grid(
timetable_reservations_table,
RsvTimetableReservationResource(<?= $id ?>),
{
'id': RsvDataGrid.column('ID', false),
'reservation_id': RsvDataGrid.action_column('Reservation', false, {
'Accept': RsvDataGrid.func_action(
(dt, row, data) => RsvReservationClient.accept(data.reservation_id).then(() => dt.refresh()),
(item) => item.pending_confirmation_id !== null
),
'Refuse': RsvDataGrid.func_action(
(dt, row, data) => RsvReservationClient.refuse(data.reservation_id).then(() => dt.refresh()),
(item) => item.pending_confirmation_id !== null
),
}),
'start_utc': RsvDataGrid.column('Start', false),
'end_utc': RsvDataGrid.column('End', false),
'is_confirmed': RsvDataGrid.column('Status', false),
}
)
.map_column('is_confirmed', (dt, row, data) => {
const td = document.createElement('td');
td.textContent = data.pending_confirmation_id !== null ? 'Pending'
: data.is_confirmed == 1 ? 'Confirmed'
: 'Refused';
return td;
})
.refresh();
</script>
<?php
}
function rsv_timetable_capacity_view($id) {
$timetable_service = new RsvTimetableService();
$timetable = $timetable_service->get($id);
$gcal_service = new RsvGoogleCalendarService();
$gcal_connected = $gcal_service->is_google_connected();
$current_calendar_id = $timetable->google_calendar_id ?? null;
$existing_emails = $timetable_service->get_all_maintainer_emails();
?>
<h2>Settings</h2>
<datalist id="maintainer_email_list">
<?php foreach ($existing_emails as $email): ?>
<option value="<?= esc_attr($email) ?>">
<?php endforeach; ?>
</datalist>
<form id="timetable_settings_form"
action="<?= esc_url(get_rest_url(null, 'reservations/v1/timetable/' . $id)) ?>"
data-method="PATCH"
data-success-msg="Settings saved.">
private function show_capacity(int $id): void {
$timetable_service = new RsvTimetableService();
$timetable = $timetable_service->get($id);
$gcal_service = new RsvGoogleCalendarService();
$gcal_connected = $gcal_service->is_google_connected();
$current_calendar_id = $timetable->google_calendar_id ?? null;
$existing_emails = $timetable_service->get_all_maintainer_emails();
?>
<?php
RsvFormBuilder::create("timetable_settings_form")
RsvFormBuilder::create('timetable_settings_form', get_rest_url(null, 'reservations/v1/timetable/' . $id), 'PATCH', 'Settings saved.')
->heading('Settings')
->datalist('maintainer_email_suggestions', $existing_emails)
->text('name', 'Name', 'Name of the timetable that can be reserved.', true, $timetable->name)
->number('block_size', 'Block length (minutes)', 'Duration of one reservable time block in minutes.', true, $timetable->block_size, 1)
->email('maintainer_email', 'Maintainer Email', 'Email address to notify when a reservation requires confirmation.', false, $timetable->maintainer_email ?? '', 'maintainer_email_suggestions')
@@ -189,246 +162,213 @@ function rsv_timetable_capacity_view($id) {
->submit('Save Settings', 'button-primary', 'submit')
->output();
?>
</form>
<script>
(function() {
const gcalSelect = document.getElementById('gcal_select');
const currentCalId = <?= json_encode($current_calendar_id) ?>;
<script>
(function() {
const gcalSelect = document.getElementById('gcal_select');
const currentCalId = <?= json_encode($current_calendar_id) ?>;
if (gcalSelect) {
fetch('<?= esc_js(get_rest_url(null, 'reservations/v1/google-calendars')) ?>', {
credentials: 'same-origin',
headers: { 'X-WP-Nonce': ReservairServiceAPI.nonce },
})
.then(r => r.json())
.then(calendars => {
for (const cal of calendars) {
const opt = document.createElement('option');
opt.value = cal.id;
opt.textContent = cal.summary;
if (cal.id === currentCalId) opt.selected = true;
gcalSelect.appendChild(opt);
}
})
.catch(() => {
gcalSelect.disabled = true;
gcalSelect.options[0].textContent = 'Failed to load calendars';
});
}
RsvAdminForm.bind(timetable_settings_form);
})();
</script>
<h2>Capacity</h2>
<p>Define capacities for timetable.</p>
<?php
rsv_create_capacity_form($id);
?>
<script>
(function() {
const DAY_DOW = { monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 0 };
function nearest_weekday(base_str, dow) {
const d = new Date(base_str + 'T00:00:00');
const diff = (dow - d.getDay() + 7) % 7;
d.setDate(d.getDate() + diff);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
RsvAdminForm.bind(document.getElementById('create_capacity_form'), {
// Expand one form submission into one capacity row per selected weekday.
transform: (body, form) => {
const fd = new FormData(form);
const is_repeating = fd.get('is_repeating') !== null;
const repeat_period_in_days = is_repeating
? parseInt(fd.get('repeat_period_in_days')) * parseInt(fd.get('repeat_period_multiplier'))
: 1;
const repeat_times = is_repeating ? parseInt(fd.get('repeat_times')) : 0;
const common = {
start_time: time_to_minutes(fd.get('start_time')),
end_time: time_to_minutes(fd.get('end_time')),
capacity: parseInt(fd.get('capacity')),
min_lead_time_minutes: parseInt(fd.get('min_lead_time_minutes')),
requires_confirmation: fd.get('requires_confirmation') !== null,
repeat_period_in_days,
repeat_times,
};
const base_date = fd.get('date');
const selected_days = Object.keys(DAY_DOW).filter(day => fd.get(day) !== null);
return selected_days.length > 0
? selected_days.map(day => ({ ...common, date: nearest_weekday(base_date, DAY_DOW[day]) }))
: [{ ...common, date: base_date }];
},
refresh: () => capacity_dt.refresh(),
});
})();
</script>
<div id="capacity_table"></div>
<script>
function minutes_to_time(minutes) {
const h = Math.floor(minutes / 60).toString().padStart(2, '0');
const m = (minutes % 60).toString().padStart(2, '0');
return `${h}:${m}`;
}
function time_to_minutes(time_str) {
const [h, m] = time_str.split(':').map(Number);
return h * 60 + m;
}
function rsv_render_capacity_inline_form(dt, row, data) {
const resource_with_time_conversion = {
...dt.resource,
put(id, form_data) {
return dt.resource.put(id, {
...form_data,
start_time: time_to_minutes(form_data.start_time),
end_time: time_to_minutes(form_data.end_time),
});
},
};
return RsvInlineFormBuilder.create(resource_with_time_conversion)
.input_hidden('min_lead_time_minutes', 0)
.fieldset('Datum a čas', '50%')
.input_date('date', 'Datum', data?.date ?? '')
.input_time('start_time', 'Začátek', minutes_to_time(data?.start_time ?? 0))
.input_time('end_time', 'Konec', minutes_to_time(data?.end_time ?? 0))
.fieldset('Kapacita a opakování', '50%')
.input_number('capacity', 'Kapacita', data?.capacity ?? '')
.input_number('repeat_period_in_days', 'Dnů mezi opakováním', data?.repeat_period_in_days ?? '')
.input_number('repeat_times', 'Počet opakování', data?.repeat_times ?? '')
.input_checkbox('requires_confirmation', 'Vyžaduje potvrzení', data?.requires_confirmation == 1)
.build({
id: data?.id,
colspan: 8,
save_label: 'Aktualizovat',
on_success: () => capacity_dt.refresh(),
on_cancel: () => capacity_dt.refresh(),
if (gcalSelect) {
fetch('<?= esc_js(get_rest_url(null, 'reservations/v1/google-calendars')) ?>', {
credentials: 'same-origin',
headers: { 'X-WP-Nonce': ReservairServiceAPI.nonce },
})
.then(r => r.json())
.then(calendars => {
for (const cal of calendars) {
const opt = document.createElement('option');
opt.value = cal.id;
opt.textContent = cal.summary;
if (cal.id === currentCalId) opt.selected = true;
gcalSelect.appendChild(opt);
}
})
.catch(() => {
gcalSelect.disabled = true;
gcalSelect.options[0].textContent = 'Failed to load calendars';
});
}
var capacity_dt = RsvDataGrid.create_data_grid(capacity_table,
RsvTimetableCapacityResource(<?= $id ?>),
{
'id': RsvDataGrid.column('ID', false),
'date': RsvDataGrid.action_column('Datum', false, {
'Edit': RsvDataGrid.edit_action((dt, row, data) => {
row.classList.add('inline-edit-row', 'inline-edit-row-post', 'quick-edit-row', 'quick-edit-row-post', 'inline-edit-post', 'inline-editor');
row.replaceChildren(rsv_render_capacity_inline_form(dt, row, data));
}),
'Trash': RsvDataGrid.func_action((dt, row, data) => {
dt.resource.delete(data.id).then(() => dt.refresh());
}),
}),
'start_time': RsvDataGrid.column('Začátek', false),
'end_time': RsvDataGrid.column('Konec', false),
'capacity': RsvDataGrid.column('Kapacita', false),
'repeat_period_in_days': RsvDataGrid.column('Dnů mezi opakováním', false),
'repeat_times': RsvDataGrid.column('Počet opakování', false),
'requires_confirmation': RsvDataGrid.column('Vyžaduje potvrzení', false),
}, );
capacity_dt.map_column('start_time', (dt, row, data) => {
const td = document.createElement('td');
td.innerText = minutes_to_time(data.start_time);
return td;
});
capacity_dt.map_column('end_time', (dt, row, data) => {
const td = document.createElement('td');
td.innerText = minutes_to_time(data.end_time);
return td;
});
capacity_dt.refresh();
</script>
<?php
}
function rsv_timetable_view_page(int $id): void {
$timetable = (new RsvTimetableService())->get($id);
if ($timetable === null) {
echo '<div class="notice notice-error"><p>Timetable not found.</p></div>';
return;
}
?>
<h1><?= esc_html($timetable->name) ?></h1>
<a href="<?= esc_url(menu_page_url('timetable-settings', false)) ?>"> Back to Timetables</a>
<hr>
<table class="form-table">
<tr><th>Name</th><td><?= esc_html($timetable->name) ?></td></tr>
<tr><th>Block size</th><td><?= esc_html($timetable->block_size) ?> minutes</td></tr>
<?php if ($timetable->maintainer_email): ?>
<tr><th>Maintainer email</th><td><?= esc_html($timetable->maintainer_email) ?></td></tr>
<?php endif; ?>
</table>
<h2>Reservations</h2>
<div id="timetable_reservations_table"></div>
<script>
RsvDataGrid.create_data_grid(
timetable_reservations_table,
RsvTimetableReservationResource(<?= $id ?>),
{
'id': RsvDataGrid.column('ID', false),
'reservation_id': RsvDataGrid.action_column('Reservation', false, {
'Accept': RsvDataGrid.func_action(
(dt, row, data) => RsvReservationClient.accept(data.reservation_id).then(() => dt.refresh()),
(item) => item.pending_confirmation_id !== null
),
'Refuse': RsvDataGrid.func_action(
(dt, row, data) => RsvReservationClient.refuse(data.reservation_id).then(() => dt.refresh()),
(item) => item.pending_confirmation_id !== null
),
}),
'start_utc': RsvDataGrid.column('Start', false),
'end_utc': RsvDataGrid.column('End', false),
'is_confirmed': RsvDataGrid.column('Status', false),
}
)
.map_column('is_confirmed', (dt, row, data) => {
const td = document.createElement('td');
td.textContent = data.pending_confirmation_id !== null ? 'Pending'
: data.is_confirmed == 1 ? 'Confirmed'
: 'Refused';
return td;
})
.refresh();
</script>
<?php
}
function rsv_timetable_edit_page($id) {
rsv_timetable_capacity_view($id);
}
RsvAdminForm.bind(timetable_settings_form);
})();
</script>
<h2>Capacity</h2>
function rsv_timetable_page() {
if (isset($_GET['action'])) {
if ($_GET['action'] === 'view' && isset($_GET['id'])) {
rsv_timetable_view_page(intval($_GET['id']));
return;
} else if($_GET['action'] === 'edit' && isset($_GET['id'])) {
rsv_timetable_edit_page(intval($_GET['id']));
return;
}
// Deletion is intentionally not handled here: a state-changing GET is
// CSRF-prone. Timetables are deleted via the nonce-authenticated REST
// DELETE /timetable/{id} (see the "Trash" action in the list grid).
<p>Define capacities for timetable.</p>
<?php $this->create_capacity_form($id); ?>
<script>
(function() {
const DAY_DOW = { monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 0 };
function nearest_weekday(base_str, dow) {
const d = new Date(base_str + 'T00:00:00');
const diff = (dow - d.getDay() + 7) % 7;
d.setDate(d.getDate() + diff);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
RsvAdminForm.bind(document.getElementById('create_capacity_form'), {
// Expand one form submission into one capacity row per selected weekday.
transform: (body, form) => {
const fd = new FormData(form);
const is_repeating = fd.get('is_repeating') !== null;
const repeat_period_in_days = is_repeating
? parseInt(fd.get('repeat_period_in_days')) * parseInt(fd.get('repeat_period_multiplier'))
: 1;
const repeat_times = is_repeating ? parseInt(fd.get('repeat_times')) : 0;
const common = {
start_time: time_to_minutes(fd.get('start_time')),
end_time: time_to_minutes(fd.get('end_time')),
capacity: parseInt(fd.get('capacity')),
min_lead_time_minutes: parseInt(fd.get('min_lead_time_minutes')),
requires_confirmation: fd.get('requires_confirmation') !== null,
repeat_period_in_days,
repeat_times,
};
const base_date = fd.get('date');
const selected_days = Object.keys(DAY_DOW).filter(day => fd.get(day) !== null);
return selected_days.length > 0
? selected_days.map(day => ({ ...common, date: nearest_weekday(base_date, DAY_DOW[day]) }))
: [{ ...common, date: base_date }];
},
refresh: () => capacity_dt.refresh(),
});
})();
</script>
<div id="capacity_table"></div>
<script>
function minutes_to_time(minutes) {
const h = Math.floor(minutes / 60).toString().padStart(2, '0');
const m = (minutes % 60).toString().padStart(2, '0');
return `${h}:${m}`;
}
function time_to_minutes(time_str) {
const [h, m] = time_str.split(':').map(Number);
return h * 60 + m;
}
function rsv_render_capacity_inline_form(dt, row, data) {
const resource_with_time_conversion = {
...dt.resource,
put(id, form_data) {
return dt.resource.put(id, {
...form_data,
start_time: time_to_minutes(form_data.start_time),
end_time: time_to_minutes(form_data.end_time),
});
},
};
return RsvInlineFormBuilder.create(resource_with_time_conversion)
.input_hidden('min_lead_time_minutes', 0)
.fieldset('Datum a čas', '50%')
.input_date('date', 'Datum', data?.date ?? '')
.input_time('start_time', 'Začátek', minutes_to_time(data?.start_time ?? 0))
.input_time('end_time', 'Konec', minutes_to_time(data?.end_time ?? 0))
.fieldset('Kapacita a opakování', '50%')
.input_number('capacity', 'Kapacita', data?.capacity ?? '')
.input_number('repeat_period_in_days', 'Dnů mezi opakováním', data?.repeat_period_in_days ?? '')
.input_number('repeat_times', 'Počet opakování', data?.repeat_times ?? '')
.input_checkbox('requires_confirmation', 'Vyžaduje potvrzení', data?.requires_confirmation == 1)
.build({
id: data?.id,
colspan: 8,
save_label: 'Aktualizovat',
on_success: () => capacity_dt.refresh(),
on_cancel: () => capacity_dt.refresh(),
});
}
var capacity_dt = RsvDataGrid.create_data_grid(capacity_table,
RsvTimetableCapacityResource(<?= $id ?>),
{
'id': RsvDataGrid.column('ID', false),
'date': RsvDataGrid.action_column('Datum', false, {
'Edit': RsvDataGrid.edit_action((dt, row, data) => {
row.classList.add('inline-edit-row', 'inline-edit-row-post', 'quick-edit-row', 'quick-edit-row-post', 'inline-edit-post', 'inline-editor');
row.replaceChildren(rsv_render_capacity_inline_form(dt, row, data));
}),
'Trash': RsvDataGrid.func_action((dt, row, data) => {
dt.resource.delete(data.id).then(() => dt.refresh());
}),
}),
'start_time': RsvDataGrid.column('Začátek', false),
'end_time': RsvDataGrid.column('Konec', false),
'capacity': RsvDataGrid.column('Kapacita', false),
'repeat_period_in_days': RsvDataGrid.column('Dnů mezi opakováním', false),
'repeat_times': RsvDataGrid.column('Počet opakování', false),
'requires_confirmation': RsvDataGrid.column('Vyžaduje potvrzení', false),
}, );
capacity_dt.map_column('start_time', (dt, row, data) => {
const td = document.createElement('td');
td.innerText = minutes_to_time(data.start_time);
return td;
});
capacity_dt.map_column('end_time', (dt, row, data) => {
const td = document.createElement('td');
td.innerText = minutes_to_time(data.end_time);
return td;
});
capacity_dt.refresh();
</script>
<?php
}
rsv_timetable_list_page();
private function create_capacity_form(int $timetable_id): void {
$form = RsvFormBuilder::create('create_capacity_form', get_rest_url(null, 'reservations/v1/timetable/' . $timetable_id . '/capacity'), 'POST', 'Capacity created.');
$form->date('date', 'First Date', 'Od kterého datumu platí tato kapacita.', true, new DateTime()->format('Y-m-d'));
$form->group('Availability Range', fn($g) => $g
->time('start_time', 'Start')
->time('end_time', 'End')
);
$form->number('capacity', 'Capacity', 'How many reservations can overlap on the same time.', true, 1, 1);
$form->number('min_lead_time_minutes', 'Minimum lead time (minutes)', 'How many minutes in advance must be the reservation created. This is useful if it takes some time to prepare the reservation.', true);
$form->checkbox('requires_confirmation', 'Requires Confirmation?', 'If checked, all the reservations that overlap this capacity will require confirmation from maintainer. The maintainer will receive an email asking for the confirmation.', true);
$form->custom('Is Repeating Event', function() {
return '
<input id="is_repeating" class="regular-text" type="checkbox" name="is_repeating" checked="true">
<p>If the capacity is available repeatingly. For example: repeat each monday every week.</p>
';
});
$form->number('repeat_period_in_days', 'Repeat Period (days)', 'How many days between each repetition.', true);
$form->custom('Apply to Days', function() {
return '
<table class="option-table">
<tbody>
<tr class="form-day-names">
<td>Monday</td>
<td>Tuesday</td>
<td>Wednesday</td>
<td>Thursday</td>
<td>Friday</td>
<td>Saturday</td>
<td>Sunday</td>
</tr>
<tr class="form-days">
<td><input class="is-repeating-input" type="checkbox" name="monday"></td>
<td><input class="is-repeating-input" type="checkbox" name="tuesday"></td>
<td><input class="is-repeating-input" type="checkbox" name="wednesday"></td>
<td><input class="is-repeating-input" type="checkbox" name="thursday"></td>
<td><input class="is-repeating-input" type="checkbox" name="friday"></td>
<td><input class="is-repeating-input" type="checkbox" name="saturday"></td>
<td><input class="is-repeating-input" type="checkbox" name="sunday"></td>
</tr>
</tbody>
</table>';
});
$form->submit('Create Capacity', 'button-primary', 'submit');
$form->output();
}
}
+62 -13
View File
@@ -5,24 +5,34 @@ namespace Reservair\Forms;
/**
* Fluent builder for WordPress admin settings forms.
*
* Renders a <table class="form-table"> with one row per field.
* Hidden inputs and datalist elements are emitted before the table;
* notices before, submit button after.
* Renders a self-contained "form-wrap" card: an optional heading and a <form>
* wrapping a <table class="form-table"> with one row per field. Hidden inputs
* and datalist elements are emitted before the table; notices before the card,
* submit button after the fields.
*
* Usage:
* echo RsvFormBuilder::create()
* echo RsvFormBuilder::create('settings', $action, 'PATCH', 'Saved.')
* ->heading('Settings')
* ->text('name', 'Name', required: true)
* ->email('email', 'Email')
* ->submit('Save');
*/
class RsvFormBuilder
{
private string $form_id = "";
private string $form_id;
/** Where the form submits, and how RsvAdminForm should send it. */
private string $action;
private string $rest_method;
private string $success_msg;
/** Optional heading shown inside the card. */
private string $heading = '';
/** @var string[] Rendered before the table (hidden inputs, datalists). */
private array $before = [];
/** @var string[] WP admin notice banners rendered before the table. */
/** @var string[] WP admin notice banners rendered before the card. */
private array $notices = [];
/** @var string[] <tr> elements inside the table. */
@@ -33,9 +43,29 @@ class RsvFormBuilder
private function __construct() {}
public static function create(string $id): static
/**
* @param string $rest_method Verb sent via data-method (POST, PUT, PATCH…).
* @param string $success_msg Message RsvAdminForm shows on success.
*/
public static function create(
string $id,
string $action,
string $rest_method = 'POST',
string $success_msg = ''
): static {
$builder = new static();
$builder->form_id = $id;
$builder->action = $action;
$builder->rest_method = $rest_method;
$builder->success_msg = $success_msg;
return $builder;
}
/** Heading shown inside the card, above the fields. */
public function heading(string $text): static
{
return new static();
$this->heading = $text;
return $this;
}
// -------------------------------------------------------------------------
@@ -206,6 +236,13 @@ class RsvFormBuilder
return $this;
}
/** WordPress nonce field, emitted inside the form before the table. */
public function nonce(string $action, string $name): static
{
$this->before[] = wp_nonce_field($action, $name, true, false);
return $this;
}
/**
* <datalist> element for email/text suggestions emitted before the table.
*
@@ -257,15 +294,27 @@ class RsvFormBuilder
public function render(): string
{
$html = implode('', $this->notices);
$html .= implode('', $this->before);
$inner = implode('', $this->before);
if (!empty($this->rows)) {
$html .= '<table class="form-table"><tbody>' . implode('', $this->rows) . '</tbody></table>';
$inner .= '<table class="form-table"><tbody>' . implode('', $this->rows) . '</tbody></table>';
}
$html .= implode('', $this->after);
return $html;
$inner .= implode('', $this->after);
$success = $this->success_msg !== '' ? ' data-success-msg="' . esc_attr($this->success_msg) . '"' : '';
$form = '<form id="' . esc_attr($this->form_id) . '"'
. ' action="' . esc_url($this->action) . '"'
. ' method="post"'
. ' data-method="' . esc_attr($this->rest_method) . '"'
. $success . '>'
. $inner
. '</form>';
$heading = $this->heading !== '' ? '<h2>' . esc_html($this->heading) . '</h2>' : '';
return implode('', $this->notices)
. '<div class="form-wrap">' . $heading . $form . '</div>';
}
public function output(): void
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace Reservair\Layout;
/**
* Fluent builder for multi-column admin page layouts.
*
* Lets a page declare the shape it wants (e.g. a two-column split) without
* committing to any markup or styling. Each column's content is supplied as a
* callable that echoes inline HTML and <script> blocks work as usual.
*
* Usage:
* RsvColumnLayout::split('1:2')
* ->column(function () { ?>
* <h2>Add timetable</h2>
* ...
* <?php })
* ->column(function () { ?>
* <div id="availability_table"></div>
* ...
* <?php })
* ->output();
*/
class RsvColumnLayout
{
/** @var list<callable():void> Column content, in left-to-right order. */
private array $columns = [];
/** @var list<int> Relative column weights, parsed from the ratio. */
private array $weights;
private function __construct(string $ratio)
{
$this->weights = array_map('intval', explode(':', $ratio));
}
/**
* Side-by-side columns that stack on narrow screens.
*
* @param string $ratio Relative column widths, e.g. '1:1', '1:2'.
*/
public static function split(string $ratio = '1:1'): static
{
return new static($ratio);
}
/** Adds the next column. $render echoes the column's content. */
public function column(callable $render): static
{
$this->columns[] = $render;
return $this;
}
public function render(): string
{
$cols = '';
foreach ($this->columns as $i => $render) {
$grow = $this->weights[$i] ?? end($this->weights) ?: 1;
$cols .= '<div class="rsv-col" style="--rsv-col-grow:' . (int) $grow . '">'
. $this->capture($render)
. '</div>';
}
return '<div class="rsv-cols">' . $cols . '</div>';
}
public function output(): void
{
echo $this->render();
}
/** Runs an echoing callable and returns what it printed. */
private function capture(callable $render): string
{
ob_start();
$render();
return ob_get_clean();
}
}
+2
View File
@@ -7,6 +7,7 @@ import { RsvInlineFormBuilder } from '../assets/js/forms/RsvInlineFormBuilder.js
import './components/admin.js';
import { RsvAdminForm } from '../assets/js/forms/RsvAdminForm.js';
import { RsvReservationResource } from '../assets/js/datasource/RsvReservationResource.js';
import { RsvFormDefinitionResource } from '../assets/js/datasource/RsvFormDefinitionResource.js';
import { RsvTimetableResource } from '../assets/js/datasource/RsvTimetableResource.js';
import { RsvTimetableCapacityResource } from '../assets/js/datasource/RsvTimetableCapacityResource.js';
import { RsvTimetableReservationResource } from '../assets/js/datasource/RsvTimetableReservationResource.js';
@@ -16,6 +17,7 @@ window.RsvDataGrid = RsvDataGrid;
window.RsvInlineFormBuilder = RsvInlineFormBuilder;
window.RsvAdminForm = RsvAdminForm;
window.RsvReservationResource = RsvReservationResource;
window.RsvFormDefinitionResource = RsvFormDefinitionResource;
window.RsvTimetableResource = RsvTimetableResource;
window.RsvTimetableCapacityResource = RsvTimetableCapacityResource;
window.RsvTimetableReservationResource = RsvTimetableReservationResource;
-138
View File
@@ -1,99 +1,3 @@
import { get_rest_url } from '../../assets/js/RsvApi.js';
async function fetch_reservations_to_confirm(object_id) {
const url = `/wordpress/wp-json/reservations/v1/object/${object_id}/timetable/reservation/unconfirmed`;
return await fetch(url, {
method: 'GET'
}).then(x => x.json());
}
async function confirm_reservation(confirmation_code) {
const url = `/wordpress/wp-json/reservations/v1/accept/${confirmation_code}`;
const x = await fetch(url, {
method: 'GET'
});
}
async function refuse_reservation(confirmation_code) {
const url = `/wordpress/wp-json/reservations/v1/refuse/${confirmation_code}`;
const x = await fetch(url, {
method: 'GET'
});
}
async function render_unconfirmed_reservations_table(self) {
const reservations = await fetch_reservations_to_confirm(self.object_id);
const rows = reservations.map(reservation => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${reservation.date}</td>
<td>${get_format_time(new Date(`${reservation.date}T${reservation.start}`))}</td>
<td>${get_format_time(add_minutes(new Date(`${reservation.date}T${reservation.start}`), parseInt(reservation.num_minutes)))}</td>
<td>${reservation.num_minutes} min</td>
<td>${reservation.email}</td>
`;
let td = document.createElement('td');
let confirm_button = document.createElement('button');
confirm_button.classList.add('button');
confirm_button.classList.add('button-primary');
confirm_button.onclick = function() {
confirm_reservation(reservation.confirmation_code)
.then(x => self.refresh());
};
confirm_button.innerText = "Confirm";
td.appendChild(confirm_button);
let refuse_button = document.createElement('button');
refuse_button.classList.add('button');
refuse_button.classList.add('button-secondary');
refuse_button.onclick = function() {
confirm_reservation(reservation.confirmation_code)
.then(x => self.refresh());
};
refuse_button.innerText = "Refuse";
td.appendChild(refuse_button);
row.appendChild(td);
return row;
});
self.body.replaceChildren(...rows);
}
function create_unconfirmed_reservations(object_id, container) {
let table = document.createElement('table');
table.classList.add('widefat');
let header = document.createElement('thead');
header.innerHTML = `
<tr>
<th>Date</th>
<th>From</th>
<th>To</th>
<th>Length</th>
<th>Email</th>
<th>Actions</th>
</tr>
`;
table.appendChild(header);
let body = document.createElement('tbody');
table.appendChild(body);
container.appendChild(table);
return {
object_id: object_id,
container: container,
body: body,
refresh() {
render_unconfirmed_reservations_table(this);
}
};
}
function create_notice(id, type, mesg) {
let container = document.createElement('div');
container.id = id;
@@ -107,45 +11,3 @@ export function show_notice(target, type, mesg) {
const notice = create_notice('test', type, mesg);
target.prepend(notice);
}
async function error_handler(error) {
if(error.body != null && error.body.message != null) {
show_notice(error.target, 'error', error.body.message);
}
console.error(error);
}
async function delete_action(id) {
return await fetch(get_rest_url(`action/${id}`), {
method: 'DELETE'
});
}
function collect_selected_actions(target) {
return Array.from(target.querySelectorAll('input[type="checkbox"].action-selector:checked')).map(x =>
x.value
);
}
async function delete_object(id) {
return await fetch(get_rest_url(`object/${id}`), {
method: 'DELETE'
});
}
async function set_object_actions(id, actions) {
return await fetch(get_rest_url(`object/${id}/action`), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(actions)
});
}
function inline_edit_row() {
let row = document.createElement('tr');
row.classList.add('iedit author-self level-0 post-1 type-post status-publish format-standard hentry category-uncategorized');
return row;
}