From 37bced77f4ccd7d8df1c0312d907fd96c4c40646 Mon Sep 17 00:00:00 2001 From: Martin Slachta Date: Fri, 12 Jun 2026 16:05:14 +0200 Subject: [PATCH] (#2) - forms improvements --- .phpactor.json | 5 +- FORMS.md | 67 ++ assets/css/RsvAdminStyle.css | 6 +- assets/css/components/RsvFormStyles.css | 5 +- assets/css/components/RsvTimeSlotsStyles.css | 2 +- composer.json | 9 +- composer.lock | 2 +- includes/RsvAdminMenuDefinition.php | 13 +- includes/RsvAdminPage.php | 15 + includes/Views/RsvFormsPage.php | 676 ++++++++--------- .../Views/RsvGoogleCalendarSettingsPage.php | 89 ++- includes/Views/RsvReservationsPage.php | 409 +++++------ includes/Views/RsvTimetablePage.php | 690 ++++++++---------- modules/Forms/RsvFormBuilder.php | 75 +- modules/Layout/RsvColumnLayout.php | 78 ++ src/admin.js | 2 + src/components/admin.js | 138 ---- 17 files changed, 1152 insertions(+), 1129 deletions(-) create mode 100644 FORMS.md create mode 100644 includes/RsvAdminPage.php create mode 100644 modules/Layout/RsvColumnLayout.php diff --git a/.phpactor.json b/.phpactor.json index c016e80..d009f91 100644 --- a/.phpactor.json +++ b/.phpactor.json @@ -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" + ] } \ No newline at end of file diff --git a/FORMS.md b/FORMS.md new file mode 100644 index 0000000..f8ed7dd --- /dev/null +++ b/FORMS.md @@ -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 `
` 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: + +``` + + + + + +``` + +If the collector would collect exact elements, like ``, 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. diff --git a/assets/css/RsvAdminStyle.css b/assets/css/RsvAdminStyle.css index bb48fa0..c508438 100644 --- a/assets/css/RsvAdminStyle.css +++ b/assets/css/RsvAdminStyle.css @@ -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) ──────────────────────── */ diff --git a/assets/css/components/RsvFormStyles.css b/assets/css/components/RsvFormStyles.css index 75c0685..e777136 100644 --- a/assets/css/components/RsvFormStyles.css +++ b/assets/css/components/RsvFormStyles.css @@ -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; diff --git a/assets/css/components/RsvTimeSlotsStyles.css b/assets/css/components/RsvTimeSlotsStyles.css index e611519..8af4771 100644 --- a/assets/css/components/RsvTimeSlotsStyles.css +++ b/assets/css/components/RsvTimeSlotsStyles.css @@ -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; diff --git a/composer.json b/composer.json index 5ec36f7..3c0eae4 100644 --- a/composer.json +++ b/composer.json @@ -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"] diff --git a/composer.lock b/composer.lock index c5069f1..20d242d 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/includes/RsvAdminMenuDefinition.php b/includes/RsvAdminMenuDefinition.php index bffad51..e545ec4 100644 --- a/includes/RsvAdminMenuDefinition.php +++ b/includes/RsvAdminMenuDefinition.php @@ -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'] ); } diff --git a/includes/RsvAdminPage.php b/includes/RsvAdminPage.php new file mode 100644 index 0000000..8488de1 --- /dev/null +++ b/includes/RsvAdminPage.php @@ -0,0 +1,15 @@ +'; + $this->render_content(); + echo ''; + } + + abstract protected function render_content(): void; +} diff --git a/includes/Views/RsvFormsPage.php b/includes/Views/RsvFormsPage.php index ce15e23..dba1cb0 100644 --- a/includes/Views/RsvFormsPage.php +++ b/includes/Views/RsvFormsPage.php @@ -1,284 +1,42 @@ - - handlers); - $elements_with_ids = []; - $next_id = 1; - $timetables = (new RsvTimetableService())->get_all(); - ?> - -

Formuláře

-
-
-
-
-
-

Přidat formulář

- - - - - text('name', 'Název')->render(); ?> - -
- + 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(); + ?> +

Formuláře

+
+ 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(); + ?>

-
-
- -
-
+ column(function () { ?>
-
-
-
+ output(); + ?> - - - - handlers); - - $repo = new RsvFormDefinitionRepository(); - $form_def = $repo->get($id); - - if ($form_def === null) { - echo '

Form definition not found.

'; - return; + elements_table_script($elements_with_ids, $next_id, 'add_form_definition', $element_types, $timetables); ?> + 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(); - - ?> -

Edit Form:

- ← Back to Forms -
- -
- - 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 Elements

-

Define the fields that will appear in this form.

-
-

- -

-

- -

- - - -

Form definition not found.

'; 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; + } + + ?> +

Edit Form:

+ ← Back to Forms +
+ + 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(); + ?> + +
+

Form Elements

+

Define the fields that will appear in this form.

+
+

+ +

+

+ +

+ + elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables); ?> + + + '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()); - ?> -
+ 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()); + ?>

Google Calendar

@@ -127,6 +124,6 @@ function rsv_google_calendar_settings_page(): void { -
- -

Form Submissions

+class RsvReservationsPage extends RsvAdminPage { -
-
+ protected function render_content(): void { + ?> +

Form Submissions

- - [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 = `/${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 = `/${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(); + + - -

Timetables

-
-
-
-
-
-

Add timetable

+ $this->show_list(); + } - get_all_maintainer_emails(); - $existing_emails_json = json_encode($existing_emails); - ?> - - - + private function show_list(): void { + ?> +

Timetables

+
+ get_all_maintainer_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(); - ?> -
- -
-
-
-
-
-
- -
+ 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(); + ?> + + column(function () { ?> +
-
-
-
- output(); + ?> + 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 ' - -

If the capacity is available repeatingly. For example: repeat each monday every week.

- '; - }); - $form->number('repeat_period_in_days', 'Repeat Period (days)', 'How many days between each repetition.', true); - $form->custom('Apply to Days', function() { - return ' - - - - - - - - - - - - - - - - - - - - - -
MondayTuesdayWednesdayThursdayFridaySaturdaySunday
'; - }); - $form->submit('Create Capacity', 'button-primary', 'submit'); + private function show_view(int $id): void { + $timetable = (new RsvTimetableService())->get($id); + if ($timetable === null) { + echo '

Timetable not found.

'; + return; + } + ?> +

name) ?>

+ ← Back to Timetables +
- ?> -
- output(); + + + + maintainer_email): ?> + + +
Namename) ?>
Block sizeblock_size) ?> minutes
Maintainer emailmaintainer_email) ?>
- ?> -
- Reservations +
+ + 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(); - ?> - -

Settings

- - - - - -
+ 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(); + ?> 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(); ?> -
- - -

Capacity

- -

Define capacities for timetable.

- - - - -
- - - get($id); - if ($timetable === null) { - echo '

Timetable not found.

'; - return; - } - ?> -

name) ?>

- ← Back to Timetables -
- - - - - maintainer_email): ?> - - -
Namename) ?>
Block sizeblock_size) ?> minutes
Maintainer emailmaintainer_email) ?>
- -

Reservations

-
- - +

Capacity

-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). +

Define capacities for timetable.

+ + create_capacity_form($id); ?> + + + +
+ + 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 ' + +

If the capacity is available repeatingly. For example: repeat each monday every week.

+ '; + }); + $form->number('repeat_period_in_days', 'Repeat Period (days)', 'How many days between each repetition.', true); + $form->custom('Apply to Days', function() { + return ' + + + + + + + + + + + + + + + + + + + + + +
MondayTuesdayWednesdayThursdayFridaySaturdaySunday
'; + }); + $form->submit('Create Capacity', 'button-primary', 'submit'); + + $form->output(); + } } diff --git a/modules/Forms/RsvFormBuilder.php b/modules/Forms/RsvFormBuilder.php index 63fe90b..bb2ca7c 100644 --- a/modules/Forms/RsvFormBuilder.php +++ b/modules/Forms/RsvFormBuilder.php @@ -5,24 +5,34 @@ namespace Reservair\Forms; /** * Fluent builder for WordPress admin settings forms. * - * Renders a 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 + * wrapping a
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[] 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; + } + /** * 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 .= '
' . implode('', $this->rows) . '
'; + $inner .= '' . implode('', $this->rows) . '
'; } - $html .= implode('', $this->after); - return $html; + $inner .= implode('', $this->after); + + $success = $this->success_msg !== '' ? ' data-success-msg="' . esc_attr($this->success_msg) . '"' : ''; + $form = '' + . $inner + . ''; + + $heading = $this->heading !== '' ? '

' . esc_html($this->heading) . '

' : ''; + + return implode('', $this->notices) + . '
' . $heading . $form . '
'; } public function output(): void diff --git a/modules/Layout/RsvColumnLayout.php b/modules/Layout/RsvColumnLayout.php new file mode 100644 index 0000000..fb32b20 --- /dev/null +++ b/modules/Layout/RsvColumnLayout.php @@ -0,0 +1,78 @@ + blocks work as usual. + * + * Usage: + * RsvColumnLayout::split('1:2') + * ->column(function () { ?> + *

Add timetable

+ * ... + * column(function () { ?> + *
+ * ... + * output(); + */ +class RsvColumnLayout +{ + /** @var list Column content, in left-to-right order. */ + private array $columns = []; + + /** @var list 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 .= '
' + . $this->capture($render) + . '
'; + } + return '
' . $cols . '
'; + } + + 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(); + } +} diff --git a/src/admin.js b/src/admin.js index 3a78892..311cba9 100644 --- a/src/admin.js +++ b/src/admin.js @@ -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; diff --git a/src/components/admin.js b/src/components/admin.js index 6e0c35b..aa6e7cd 100644 --- a/src/components/admin.js +++ b/src/components/admin.js @@ -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 = ` - ${reservation.date} - ${get_format_time(new Date(`${reservation.date}T${reservation.start}`))} - ${get_format_time(add_minutes(new Date(`${reservation.date}T${reservation.start}`), parseInt(reservation.num_minutes)))} - ${reservation.num_minutes} min - ${reservation.email} - `; - - 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 = ` - - Date - From - To - Length - Email - Actions - - `; - - 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; -} -- 2.52.0