(#2) - forms improvements #4

Merged
Martas merged 1 commits from feat/claude/2-forms-improvements into main 2026-06-12 14:05:50 +00:00
17 changed files with 1152 additions and 1129 deletions
Showing only changes of commit 37bced77f4 - Show all commits
+4 -1
View File
@@ -1,4 +1,7 @@
{ {
"$schema": "/phpactor.schema.json", "$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%; } .rsv-cols { display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: flex-start; }
#col-right { width: 70%; }*/ .rsv-cols > .rsv-col { flex: var(--rsv-col-grow, 1) 1 0; min-width: 18rem; }
/* ─── Inline detail expand row (Reservations page) ──────────────────────── */ /* ─── Inline detail expand row (Reservations page) ──────────────────────── */
+3 -2
View File
@@ -103,10 +103,10 @@
border-color: var(--color-blue-500); border-color: var(--color-blue-500);
} }
.rsv-form-input input:user-invalid { /*.rsv-form-input input:user-invalid {
border-color: var(--color-red-500); border-color: var(--color-red-500);
box-shadow: 0 0 0 4px color-mix(in oklab,var(--color-red-500)25%,transparent); box-shadow: 0 0 0 4px color-mix(in oklab,var(--color-red-500)25%,transparent);
} }*/
.rsv-form-section { .rsv-form-section {
margin-bottom: var(--s-5); margin-bottom: var(--s-5);
@@ -159,6 +159,7 @@
padding-left: 5pt; padding-left: 5pt;
} }
.rsv-form-input:user-invalid,
.rsv-invalid { .rsv-invalid {
border-color: var(--color-red-500) !important; border-color: var(--color-red-500) !important;
box-shadow: 0 0 0 4px color-mix(in oklab, var(--color-red-500) 25%, transparent) !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; margin-top: 0.375rem;
border: 1.5px solid #e8e8e8; border: 1.5px solid #e8e8e8;
border-radius: 10px; border-radius: 10px;
padding: 9px 12px; padding: 0.25rem;
cursor: pointer; cursor: pointer;
transition: border-color .12s, background .12s, color .12s; transition: border-color .12s, background .12s, color .12s;
display: flex; display: flex;
+3 -6
View File
@@ -12,17 +12,14 @@
"files": [ "files": [
"includes/RsvAdminMenuDefinition.php", "includes/RsvAdminMenuDefinition.php",
"includes/RsvAssetsDefinition.php", "includes/RsvAssetsDefinition.php",
"includes/RsvRestApiDefinition.php", "includes/RsvRestApiDefinition.php"
"includes/Views/RsvFormsPage.php",
"includes/Views/RsvReservationsPage.php",
"includes/Views/RsvTimetablePage.php",
"includes/Views/RsvGoogleCalendarSettingsPage.php"
] ]
}, },
"require-dev": { "require-dev": {
"johnpbloch/wordpress-core": "^6.9", "johnpbloch/wordpress-core": "^6.9",
"vimeo/psalm": "^6.16", "vimeo/psalm": "^6.16",
"humanmade/psalm-plugin-wordpress": "^3.1" "humanmade/psalm-plugin-wordpress": "^3.1",
"php-stubs/wordpress-stubs": "^6.9"
}, },
"scripts": { "scripts": {
"lint": ["phpcs", "phpstan analyse", "psalm --find-dead-code"] "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", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "c4e0cf49edc636becbde269300f26001", "content-hash": "b4f5229f78cd0eed0c7166614bf05110",
"packages": [ "packages": [
{ {
"name": "chillerlan/php-qrcode", "name": "chillerlan/php-qrcode",
+9 -4
View File
@@ -4,12 +4,17 @@
* Contains definitions of the admin menus * Contains definitions of the admin menus
*/ */
function rsv_admin_menu_definition() { function rsv_admin_menu_definition() {
$reservations = new RsvReservationsPage();
$forms = new RsvFormsPage();
$timetable = new RsvTimetablePage();
$google_cal = new RsvGoogleCalendarSettingsPage();
add_menu_page( add_menu_page(
'Reservations Settings', // Page title 'Reservations Settings', // Page title
'Reservations', // Menu title 'Reservations', // Menu title
RsvCapabilities::MANAGE, // Capability RsvCapabilities::MANAGE, // Capability
'reservations-settings', // Menu slug 'reservations-settings', // Menu slug
'rsv_reservations_page', // Callback [$reservations, 'render'], // Callback
'dashicons-calendar', // Icon 'dashicons-calendar', // Icon
20 // Position 20 // Position
); );
@@ -20,7 +25,7 @@ function rsv_admin_menu_definition() {
'Forms', 'Forms',
RsvCapabilities::MANAGE, RsvCapabilities::MANAGE,
'forms-settings', 'forms-settings',
'rsv_forms_page' [$forms, 'render']
); );
add_submenu_page( add_submenu_page(
@@ -29,7 +34,7 @@ function rsv_admin_menu_definition() {
'Timetables', 'Timetables',
RsvCapabilities::MANAGE, RsvCapabilities::MANAGE,
'timetable-settings', 'timetable-settings',
'rsv_timetable_page' [$timetable, 'render']
); );
add_submenu_page( add_submenu_page(
@@ -38,6 +43,6 @@ function rsv_admin_menu_definition() {
'Google Calendar', 'Google Calendar',
RsvCapabilities::MANAGE, RsvCapabilities::MANAGE,
'rsv-google-calendar', '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;
}
+161 -157
View File
@@ -1,11 +1,150 @@
<?php <?php
use Reservair\Forms\RsvFormBuilder; use Reservair\Forms\RsvFormBuilder;
use Reservair\Layout\RsvColumnLayout;
// Shared inline script for the elements data grid. class RsvFormsPage extends RsvAdminPage {
// $elements_with_ids: array of element objects already carrying an 'id' key.
// $next_id: the first integer not yet used as an id. protected function render_content(): void {
function rsv_elements_table_script(array $elements_with_ids, int $next_id, string $form_id, array $element_types, array $timetables = []): void { if (isset($_GET['action']) && $_GET['action'] === 'edit' && isset($_GET['id'])) {
$this->show_edit(intval($_GET['id']));
return;
}
$this->show_list();
}
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>
<?php })
->column(function () { ?>
<div id="forms_table"></div>
<script>
var forms_dt = RsvDataGrid.create_data_grid(forms_table,
RsvFormDefinitionResource(), {
'form_id': RsvDataGrid.column('ID', false, 30),
'name': RsvDataGrid.action_column('Název', false, {
'Edit': RsvDataGrid.link_action((data) =>
`<?= 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) {
const base_url = `<?= get_rest_url(null, 'reservations/v1/form-definition') ?>`;
fetch(`${base_url}/${data.form_id}`, {
headers: { 'Accept': 'application/json' },
})
.then(r => {
if (!r.ok) return r.json().then(err => { throw new Error(err.error || 'Fetch failed'); });
return r.json();
})
.then(form_def => fetch(base_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({
name: 'Copy of ' + form_def.name,
definition: form_def.definition,
}),
}))
.then(r => {
if (!r.ok) return r.json().then(err => { throw new Error(err.error || 'Clone failed'); });
forms_dt.refresh();
})
.catch(err => alert(err.message));
}),
}),
});
forms_dt.refresh();
</script>
<?php })
->output();
?>
<?php $this->elements_table_script($elements_with_ids, $next_id, 'add_form_definition', $element_types, $timetables); ?>
<?php
}
private function show_edit(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;
}
$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
}
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); $elements_json = json_encode($elements_with_ids);
$types_json = json_encode(array_values($element_types)); $types_json = json_encode(array_values($element_types));
$timetables_json = json_encode(array_values($timetables)); $timetables_json = json_encode(array_values($timetables));
@@ -139,7 +278,7 @@ function rsv_elements_table_script(array $elements_with_ids, int $next_id, strin
const node = builder.build({ const node = builder.build({
id: data?.id, id: data?.id,
colspan: 5, colspan: 6,
save_label: 'Save', save_label: 'Save',
on_success: () => elements_dt.refresh(), on_success: () => elements_dt.refresh(),
on_cancel: () => elements_dt.refresh(), on_cancel: () => elements_dt.refresh(),
@@ -229,6 +368,23 @@ function rsv_elements_table_script(array $elements_with_ids, int $next_id, strin
}; };
} }
// 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 ?>'), { RsvAdminForm.bind(document.getElementById('<?= $form_id ?>'), {
transform: (body) => ({ transform: (body) => ({
name: body.name, name: body.name,
@@ -242,156 +398,4 @@ function rsv_elements_table_script(array $elements_with_ids, int $next_id, strin
</script> </script>
<?php <?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>
<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">
<div id="forms_table"></div>
<script>
var forms_dt = RsvDataGrid.create_data_grid(forms_table,
RsvFormDefinitionResource(), {
'form_id': RsvDataGrid.column('ID', false, 30),
'name': RsvDataGrid.action_column('Název', false, {
'Edit': RsvDataGrid.link_action((data) =>
`<?= menu_page_url('forms-settings', false) ?>&id=${data.form_id}&action=edit`
),
'Trash': RsvDataGrid.func_action(function(dt, row, data) {
dt.resource.delete(data.form_id).then(() => forms_dt.refresh()).catch(err => alert(err.message));
}),
'Clone': RsvDataGrid.func_action(function(dt, row, data) {
const base_url = `<?= get_rest_url(null, 'reservations/v1/form-definition') ?>`;
fetch(`${base_url}/${data.form_id}`, {
headers: { 'Accept': 'application/json' },
})
.then(r => {
if (!r.ok) return r.json().then(err => { throw new Error(err.error || 'Fetch failed'); });
return r.json();
})
.then(form_def => fetch(base_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({
name: 'Copy of ' + form_def.name,
definition: form_def.definition,
}),
}))
.then(r => {
if (!r.ok) return r.json().then(err => { throw new Error(err.error || 'Clone failed'); });
forms_dt.refresh();
})
.catch(err => alert(err.message));
}),
}),
});
forms_dt.refresh();
</script>
</div>
</div>
</div>
<?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;
}
$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();
?>
<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']));
return;
}
}
rsv_form_info_page();
} }
@@ -1,11 +1,9 @@
<?php <?php
function rsv_google_calendar_settings_page(): void { class RsvGoogleCalendarSettingsPage extends RsvAdminPage {
if (!current_user_can(RsvCapabilities::MANAGE)) {
return;
}
protected function render_content(): void {
$service = new RsvGoogleCalendarService(); $service = new RsvGoogleCalendarService();
$notice = null; $notice = null;
@@ -50,7 +48,6 @@ function rsv_google_calendar_settings_page(): void {
$cal_id = esc_attr(get_option('rsv_google_calendar_id', 'primary')); $cal_id = esc_attr(get_option('rsv_google_calendar_id', 'primary'));
$oauth_url = esc_url($service->get_oauth_url()); $oauth_url = esc_url($service->get_oauth_url());
?> ?>
<div class="wrap">
<h1>Google Calendar</h1> <h1>Google Calendar</h1>
<?php if ($notice): ?> <?php if ($notice): ?>
@@ -127,6 +124,6 @@ function rsv_google_calendar_settings_page(): void {
<?php endif; ?> <?php endif; ?>
<?php endif; ?> <?php endif; ?>
</form> </form>
</div>
<?php <?php
} }
}
+4 -1
View File
@@ -1,6 +1,8 @@
<?php <?php
function rsv_reservations_page(): void { class RsvReservationsPage extends RsvAdminPage {
protected function render_content(): void {
?> ?>
<h1>Form Submissions</h1> <h1>Form Submissions</h1>
@@ -217,3 +219,4 @@ function rsv_reservations_page(): void {
</script> </script>
<?php <?php
} }
}
+129 -189
View File
@@ -1,53 +1,46 @@
<?php <?php
use Reservair\Forms\RsvFormBuilder; use Reservair\Forms\RsvFormBuilder;
use Reservair\Layout\RsvColumnLayout;
class RsvTimetablePage extends RsvAdminPage {
function rsv_timetable_list_page() { 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).
}
$this->show_list();
}
private function show_list(): void {
?> ?>
<style>
/*#col-left {
width: 30%;
}*/
/*#col-right {
width: 70%;
}*/
</style>
<h1>Timetables</h1> <h1>Timetables</h1>
<hr> <hr>
<div id="col-container" class="wp-clearfix">
<div id="col-left">
<div class="col-wrap">
<div class="form-wrap">
<h2>Add timetable</h2>
<?php <?php
$timetable_service = new RsvTimetableService(); $timetable_service = new RsvTimetableService();
$existing_emails = $timetable_service->get_all_maintainer_emails(); $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>
<form id="add_timetable_form" RsvColumnLayout::split('1:2')
method="post" ->column(function () use ($existing_emails) {
data-method="POST" echo RsvFormBuilder::create('add_timetable_form', get_rest_url(null, 'reservations/v1/timetable'), 'POST', 'Timetable created.')
data-success-msg="Timetable created." ->heading('Add timetable')
action="<?= get_rest_url(null, 'reservations/v1/timetable'); ?>"> ->datalist('maintainer_email_suggestions', $existing_emails)
<?php
echo RsvFormBuilder::create('add_timetable_form')
->text('name', 'Name', 'Name of the timetable that can be reserved.', true) ->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) ->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') ->email('maintainer_email', 'Maintainer Email', 'Email address to notify when a reservation requires confirmation.', false, '', 'maintainer_email_suggestions')
->submit('Add Timetable') ->submit('Add Timetable')
->render(); ->render();
?> ?>
</form>
<script> <script>
RsvAdminForm.bind(add_timetable_form, { RsvAdminForm.bind(add_timetable_form, {
transform: (body) => ({ transform: (body) => ({
@@ -58,14 +51,9 @@ function rsv_timetable_list_page() {
refresh: () => availability_dt.refresh(), refresh: () => availability_dt.refresh(),
}); });
</script> </script>
</div> <?php })
</div> ->column(function () { ?>
</div> <div id="availability_table"></div>
<div id="col-right">
<div class="col-wrap">
<div id="availability_table">
</div>
<script> <script>
var availability_dt = RsvDataGrid.create_data_grid(availability_table, var availability_dt = RsvDataGrid.create_data_grid(availability_table,
RsvTimetableResource(), { RsvTimetableResource(), {
@@ -83,71 +71,67 @@ function rsv_timetable_list_page() {
}); });
availability_dt.refresh(); availability_dt.refresh();
</script> </script>
</div> <?php })
</div> ->output();
</div> ?>
<?php <?php
} }
function rsv_create_capacity_form($timetable_id) { private function show_view(int $id): void {
$form = RsvFormBuilder::create("create_capacity_form", get_rest_url(null, 'reservations/v1/timetable/' . $timetable_id . '/capacity')); $timetable = (new RsvTimetableService())->get($id);
$form->date('date', 'First Date', 'Od kterého datumu platí tato kapacita.', true, new DateTime()->format('Y-m-d')); if ($timetable === null) {
$form->group('Availability Range', fn($g) => $g echo '<div class="notice notice-error"><p>Timetable not found.</p></div>';
->time('start_time', 'Start') return;
->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 <h1><?= esc_html($timetable->name) ?></h1>
id="create_capacity_form" <a href="<?= esc_url(menu_page_url('timetable-settings', false)) ?>">← Back to Timetables</a>
data-method="POST" <hr>
data-success-msg="Capacity created."
action="<?= get_rest_url(null, 'reservations/v1/timetable/' . $timetable_id . '/capacity'); ?>" >
<?php
$form->output();
?> <table class="form-table">
</form> <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 <?php
} }
function rsv_timetable_capacity_view($id) { private function show_capacity(int $id): void {
$timetable_service = new RsvTimetableService(); $timetable_service = new RsvTimetableService();
$timetable = $timetable_service->get($id); $timetable = $timetable_service->get($id);
$gcal_service = new RsvGoogleCalendarService(); $gcal_service = new RsvGoogleCalendarService();
@@ -156,21 +140,10 @@ function rsv_timetable_capacity_view($id) {
$existing_emails = $timetable_service->get_all_maintainer_emails(); $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.">
<?php <?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) ->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) ->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') ->email('maintainer_email', 'Maintainer Email', 'Email address to notify when a reservation requires confirmation.', false, $timetable->maintainer_email ?? '', 'maintainer_email_suggestions')
@@ -189,7 +162,6 @@ function rsv_timetable_capacity_view($id) {
->submit('Save Settings', 'button-primary', 'submit') ->submit('Save Settings', 'button-primary', 'submit')
->output(); ->output();
?> ?>
</form>
<script> <script>
(function() { (function() {
@@ -225,11 +197,8 @@ function rsv_timetable_capacity_view($id) {
<p>Define capacities for timetable.</p> <p>Define capacities for timetable.</p>
<?php <?php $this->create_capacity_form($id); ?>
rsv_create_capacity_form($id);
?>
<script> <script>
(function() { (function() {
const DAY_DOW = { monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 0 }; const DAY_DOW = { monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 0 };
@@ -353,82 +322,53 @@ function rsv_timetable_capacity_view($id) {
capacity_dt.refresh(); capacity_dt.refresh();
</script> </script>
<?php <?php
} }
function rsv_timetable_view_page(int $id): void { private function create_capacity_form(int $timetable_id): void {
$timetable = (new RsvTimetableService())->get($id); $form = RsvFormBuilder::create('create_capacity_form', get_rest_url(null, 'reservations/v1/timetable/' . $timetable_id . '/capacity'), 'POST', 'Capacity created.');
if ($timetable === null) { $form->date('date', 'First Date', 'Od kterého datumu platí tato kapacita.', true, new DateTime()->format('Y-m-d'));
echo '<div class="notice notice-error"><p>Timetable not found.</p></div>'; $form->group('Availability Range', fn($g) => $g
return; ->time('start_time', 'Start')
} ->time('end_time', 'End')
?> );
<h1><?= esc_html($timetable->name) ?></h1> $form->number('capacity', 'Capacity', 'How many reservations can overlap on the same time.', true, 1, 1);
<a href="<?= esc_url(menu_page_url('timetable-settings', false)) ?>">← Back to Timetables</a> $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);
<hr> $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');
<table class="form-table"> $form->output();
<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);
}
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).
}
rsv_timetable_list_page();
} }
+62 -13
View File
@@ -5,24 +5,34 @@ namespace Reservair\Forms;
/** /**
* Fluent builder for WordPress admin settings forms. * Fluent builder for WordPress admin settings forms.
* *
* Renders a <table class="form-table"> with one row per field. * Renders a self-contained "form-wrap" card: an optional heading and a <form>
* Hidden inputs and datalist elements are emitted before the table; * wrapping a <table class="form-table"> with one row per field. Hidden inputs
* notices before, submit button after. * and datalist elements are emitted before the table; notices before the card,
* submit button after the fields.
* *
* Usage: * Usage:
* echo RsvFormBuilder::create() * echo RsvFormBuilder::create('settings', $action, 'PATCH', 'Saved.')
* ->heading('Settings')
* ->text('name', 'Name', required: true) * ->text('name', 'Name', required: true)
* ->email('email', 'Email') * ->email('email', 'Email')
* ->submit('Save'); * ->submit('Save');
*/ */
class RsvFormBuilder 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). */ /** @var string[] Rendered before the table (hidden inputs, datalists). */
private array $before = []; 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 = []; private array $notices = [];
/** @var string[] <tr> elements inside the table. */ /** @var string[] <tr> elements inside the table. */
@@ -33,9 +43,29 @@ class RsvFormBuilder
private function __construct() {} 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; 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. * <datalist> element for email/text suggestions — emitted before the table.
* *
@@ -257,15 +294,27 @@ class RsvFormBuilder
public function render(): string public function render(): string
{ {
$html = implode('', $this->notices); $inner = implode('', $this->before);
$html .= implode('', $this->before);
if (!empty($this->rows)) { 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); $inner .= implode('', $this->after);
return $html;
$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 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 './components/admin.js';
import { RsvAdminForm } from '../assets/js/forms/RsvAdminForm.js'; import { RsvAdminForm } from '../assets/js/forms/RsvAdminForm.js';
import { RsvReservationResource } from '../assets/js/datasource/RsvReservationResource.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 { RsvTimetableResource } from '../assets/js/datasource/RsvTimetableResource.js';
import { RsvTimetableCapacityResource } from '../assets/js/datasource/RsvTimetableCapacityResource.js'; import { RsvTimetableCapacityResource } from '../assets/js/datasource/RsvTimetableCapacityResource.js';
import { RsvTimetableReservationResource } from '../assets/js/datasource/RsvTimetableReservationResource.js'; import { RsvTimetableReservationResource } from '../assets/js/datasource/RsvTimetableReservationResource.js';
@@ -16,6 +17,7 @@ window.RsvDataGrid = RsvDataGrid;
window.RsvInlineFormBuilder = RsvInlineFormBuilder; window.RsvInlineFormBuilder = RsvInlineFormBuilder;
window.RsvAdminForm = RsvAdminForm; window.RsvAdminForm = RsvAdminForm;
window.RsvReservationResource = RsvReservationResource; window.RsvReservationResource = RsvReservationResource;
window.RsvFormDefinitionResource = RsvFormDefinitionResource;
window.RsvTimetableResource = RsvTimetableResource; window.RsvTimetableResource = RsvTimetableResource;
window.RsvTimetableCapacityResource = RsvTimetableCapacityResource; window.RsvTimetableCapacityResource = RsvTimetableCapacityResource;
window.RsvTimetableReservationResource = RsvTimetableReservationResource; 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) { function create_notice(id, type, mesg) {
let container = document.createElement('div'); let container = document.createElement('div');
container.id = id; container.id = id;
@@ -107,45 +11,3 @@ export function show_notice(target, type, mesg) {
const notice = create_notice('test', type, mesg); const notice = create_notice('test', type, mesg);
target.prepend(notice); 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;
}