initial
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
|
||||
class RsvButtonElementHandler implements RsvFormElementHandler {
|
||||
public function draw(RsvFormElementDefinition $element): void {
|
||||
?>
|
||||
<div class="rsv-form-input-group rsv-form-input-short">
|
||||
<button class="rsv-form-btn-primary"><?= $element->getLabel() ?></button>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function submit(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rollback(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): void {
|
||||
// No side effects to undo.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
|
||||
interface RsvFormElementHandler {
|
||||
function draw(RsvFormElementDefinition $def) : void;
|
||||
|
||||
/**
|
||||
* Validate and execute the element. Records errors on $result and returns
|
||||
* false if it cannot complete.
|
||||
*
|
||||
* @param array<int,mixed> $data
|
||||
*/
|
||||
function submit(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result) : bool;
|
||||
|
||||
/**
|
||||
* Undo a successful submit(); a no-op for elements without side effects.
|
||||
*
|
||||
* @param array<int,mixed> $data
|
||||
*/
|
||||
function rollback(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result) : void;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
use Reservair\Logger\Logger;
|
||||
|
||||
class RsvFormReservationElementHandler implements RsvFormElementHandler {
|
||||
|
||||
private function end_from_start(DateTime $start, int $block_size_minutes): DateTime {
|
||||
return ($start)->modify("+{$block_size_minutes} minutes");
|
||||
}
|
||||
|
||||
public function draw(RsvFormElementDefinition $element): void {
|
||||
$timetable_id = (int) $element->getAttr('timetable_id');
|
||||
$price_per_block = (float) $element->getAttr('price_per_block', 0);
|
||||
$name = $element->getName();
|
||||
?>
|
||||
<div class="rsv-form-input-group">
|
||||
<label><?= esc_html($element->getLabel()) ?></label>
|
||||
<rsv-reservation-selector
|
||||
timetable-id="<?= $timetable_id ?>"
|
||||
name="<?= esc_attr($name) ?>"
|
||||
price-per-block="<?= $price_per_block ?>"
|
||||
class="rsv-form-field"
|
||||
></rsv-reservation-selector>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/** Validate the payload and create the reservation. */
|
||||
public function submit(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): bool {
|
||||
$name = $def->getName();
|
||||
$payload = $data[$name] ?? null;
|
||||
|
||||
if ($def->isRequired() && is_null($payload)) {
|
||||
$result->addError($name, 'required', 'Reservation payload is required');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!is_array($payload) || !array_key_exists('timetable_reservations', $payload)) {
|
||||
$result->addError($name, 'invalid', 'Reservation payload must be an object with timetable_reservations');
|
||||
return false;
|
||||
}
|
||||
|
||||
$timetable_id = intval($payload['timetable_id'] ?? null);
|
||||
$timetable = (new RsvTimetableService())->get($timetable_id);
|
||||
|
||||
if (!$timetable) {
|
||||
$result->addError($name, 'invalid', 'Invalid timetable ID');
|
||||
return false;
|
||||
}
|
||||
|
||||
$reservation = new RsvReservation(
|
||||
0,
|
||||
$submit_id,
|
||||
false,
|
||||
array_map(fn($t) => new RsvTimetableReservation(
|
||||
0,
|
||||
$timetable_id,
|
||||
new DateTime($t),
|
||||
$this->end_from_start(new DateTime($t), $timetable->block_size)
|
||||
), $payload['timetable_reservations'])
|
||||
);
|
||||
|
||||
try {
|
||||
$reservation_id = (new RsvReservationService())->create($reservation);
|
||||
} catch (\Throwable $e) {
|
||||
// The slot may have been taken since the user selected it.
|
||||
Logger::error($e);
|
||||
$result->addError($name, 'creation_failed', 'Could not create the reservation. The slot may no longer be available.');
|
||||
return false;
|
||||
}
|
||||
|
||||
$result->setValue($name . '_reservation_id', $reservation_id);
|
||||
|
||||
$price_per_block = (float) $def->getAttr('price_per_block', 0);
|
||||
$result->setValue($name . '_price', $price_per_block * count($payload['timetable_reservations']));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Delete the reservation created by submit(). */
|
||||
public function rollback(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): void {
|
||||
$reservation_id = $result->getValue($def->getName() . '_reservation_id');
|
||||
if ($reservation_id) {
|
||||
(new RsvReservationService())->delete((int) $reservation_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
class RsvReservationSummaryElementHandler implements RsvFormElementHandler {
|
||||
|
||||
public function draw(RsvFormElementDefinition $def): void {
|
||||
?>
|
||||
<rsv-reservation-summary></rsv-reservation-summary>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function submit(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rollback(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): void {
|
||||
// No side effects to undo.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
|
||||
class RsvTextFieldElementHandler implements RsvFormElementHandler {
|
||||
|
||||
/** @return array<string, array{0: callable, 1: string}> */
|
||||
private function rules(): array {
|
||||
return [
|
||||
'email' => ['is_email', 'Please enter a valid email address.'],
|
||||
'phone' => [$this->regexPredicate('\+?[0-9 ()\-]{6,20}'), 'Please enter a valid phone number.'],
|
||||
'digits' => [$this->regexPredicate('[0-9]+'), 'Please enter digits only.'],
|
||||
];
|
||||
}
|
||||
|
||||
private function regexPredicate(string $pattern): callable {
|
||||
return fn($value): bool => (bool) @preg_match('~^(?:' . $pattern . ')$~u', (string) $value);
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:callable,2:string}|null */
|
||||
private function resolveRule(RsvFormElementDefinition $def): ?array {
|
||||
$name = $def->getAttr('validation', '');
|
||||
if ($name === '') {
|
||||
return null;
|
||||
}
|
||||
if ($name === 'pattern') {
|
||||
$pattern = $def->getAttr('pattern', '');
|
||||
if ($pattern === '') {
|
||||
return null;
|
||||
}
|
||||
return ['pattern', $this->regexPredicate($pattern), $def->getAttr('pattern_message', '') ?: 'Invalid format.'];
|
||||
}
|
||||
$rules = $this->rules();
|
||||
if (isset($rules[$name])) {
|
||||
return [$name, $rules[$name][0], $rules[$name][1]];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function draw(RsvFormElementDefinition $def): void {
|
||||
$validation = $def->getAttr('validation', '');
|
||||
$type = match ($validation) {
|
||||
'email' => 'email',
|
||||
'phone' => 'tel',
|
||||
'digits' => 'number',
|
||||
default => 'text',
|
||||
};
|
||||
$pattern = $validation === 'pattern' ? $def->getAttr('pattern', '') : '';
|
||||
?>
|
||||
<div class="rsv-form-input-group rsv-form-input-short">
|
||||
<label class="rsv-form-label"><?= $def->getLabel() ?>:</label>
|
||||
<input class="rsv-form-input rsv-form-field" type="<?= $type ?>" name="<?= $def->getName() ?>"<?= $pattern !== '' ? ' pattern="' . esc_attr($pattern) . '"' : '' ?> <?= $def->isRequired() ? "required" : "" ?>/>
|
||||
<small class="rsv-form-small"><?= $def->getDesc() ?></small>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function submit(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): bool {
|
||||
$name = $def->getName();
|
||||
$value = $data[$name] ?? null;
|
||||
|
||||
if ($def->isRequired() && (is_null($value) || $value === "")) {
|
||||
$result->addError($name, 'required', 'Field is required');
|
||||
return false;
|
||||
}
|
||||
|
||||
$rule = $this->resolveRule($def);
|
||||
if ($rule !== null && !is_null($value) && $value !== "") {
|
||||
[$code, $predicate, $message] = $rule;
|
||||
if (!$predicate($value)) {
|
||||
$result->addError($name, $code, $message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$result->setValue($name, sanitize_text_field($value));
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rollback(RsvFormElementDefinition $def, int $submit_id, array $data, RsvFormSubmitResult $result): void {
|
||||
// No side effects to undo.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
class RsvFormData {
|
||||
private array $elements = [];
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $data
|
||||
*/
|
||||
public function __construct(array $data) {
|
||||
// Expecting raw post values as associative array
|
||||
$this->elements = $data['values'] ?? $data;
|
||||
}
|
||||
|
||||
public function getElements(): array {
|
||||
return $this->elements;
|
||||
}
|
||||
|
||||
public function getValue(string $name, $default = null) {
|
||||
return $this->elements[$name] ?? $default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
class RsvFormDefinition {
|
||||
public $_elements = [];
|
||||
|
||||
public string $_id = "";
|
||||
|
||||
public string $email_key = "";
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $definition Full definition array including 'elements' and 'email_key'.
|
||||
*/
|
||||
public function __construct(string $id, array $definition) {
|
||||
$this->_elements = [];
|
||||
|
||||
if (array_key_exists('elements', $definition)) {
|
||||
foreach ($definition['elements'] as $element) {
|
||||
array_push($this->_elements, RsvFormElementDefinition::fromArray($element));
|
||||
}
|
||||
}
|
||||
|
||||
$this->_id = $id;
|
||||
$this->email_key = $definition['email_key'] ?? '';
|
||||
}
|
||||
|
||||
public function getId(): string {
|
||||
return $this->_id;
|
||||
}
|
||||
|
||||
public function getEmailKey(): string {
|
||||
return $this->email_key;
|
||||
}
|
||||
|
||||
|
||||
public function hasElements() : bool {
|
||||
return count($this->_elements) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, RsvFormElementDefinition>
|
||||
*/
|
||||
public function getElements() : array {
|
||||
return $this->_elements;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Dynamic definition of the element to be rendered or handled.
|
||||
*/
|
||||
class RsvFormElementDefinition {
|
||||
public string $type;
|
||||
public string $name;
|
||||
public string $label;
|
||||
public string $desc;
|
||||
public bool $required;
|
||||
public array $attrs = [];
|
||||
|
||||
public function getType(): string {
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function getName(): string {
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getLabel(): string {
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function getDesc(): string {
|
||||
return $this->desc;
|
||||
}
|
||||
|
||||
public function isRequired(): bool {
|
||||
return $this->required;
|
||||
}
|
||||
|
||||
public function getAttr(string $key, $default = null) {
|
||||
return $this->attrs[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $array Array for initializing the form element definition
|
||||
* @return RsvFormElementDefinition Definition of the form element for rendering and handling.
|
||||
*/
|
||||
public static function fromArray(array $array): RsvFormElementDefinition {
|
||||
$known = ['type','name','label','desc','required'];
|
||||
$attrs = array_diff_key($array, array_flip($known));
|
||||
|
||||
$def = new self(
|
||||
$array['type'],
|
||||
$array['name'],
|
||||
$array['label'],
|
||||
$array['desc'] ?? "",
|
||||
$array['required'] ?? false
|
||||
);
|
||||
|
||||
$def->attrs = $attrs;
|
||||
return $def;
|
||||
}
|
||||
|
||||
public function __construct(string $type, string $name, string $label, string $desc = "", bool $required = false) {
|
||||
$this->type = $type;
|
||||
$this->name = $name;
|
||||
$this->label = $label;
|
||||
$this->desc = $desc;
|
||||
$this->required = $required;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
|
||||
class RsvFormElementRegistry {
|
||||
/** @var array<string, RsvFormElementHandler> */
|
||||
public array $handlers = [];
|
||||
|
||||
public function register(string $type, RsvFormElementHandler $handler): void {
|
||||
$this->handlers[$type] = $handler;
|
||||
}
|
||||
|
||||
public function get(string $type): ?RsvFormElementHandler {
|
||||
return $this->handlers[$type] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
|
||||
class RsvFormHtmlRenderer {
|
||||
public function draw(RsvFormDefinition $form): bool {
|
||||
if (!$form->hasElements()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$form_id = esc_attr($form->getId());
|
||||
?>
|
||||
<div>
|
||||
<form class="reservair-form confirmation"
|
||||
id="<?= $form_id ?>"
|
||||
onsubmit="RsvFormSender.send_form(event)"
|
||||
method="POST">
|
||||
|
||||
<?php foreach ($form->getElements() as $element): ?>
|
||||
<?php $this->draw_element($element); ?>
|
||||
<?php endforeach; ?>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
<?php
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function draw_element(RsvFormElementDefinition $data): void {
|
||||
global $rsv_form_registry;
|
||||
|
||||
$handler = $rsv_form_registry->get($data->getType());
|
||||
if ($handler === null) {
|
||||
return;
|
||||
}
|
||||
$handler->draw($data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use Reservair\Logger\Logger;
|
||||
|
||||
class RsvFormProcessor {
|
||||
/** Submit every element. If one fails, undo the ones that already succeeded. */
|
||||
public function submit(RsvFormDefinition $definition, int $submit_id, RsvFormData $data): RsvFormSubmitResult {
|
||||
global $rsv_form_registry;
|
||||
|
||||
$result = new RsvFormSubmitResult();
|
||||
$committed = [];
|
||||
|
||||
foreach ($definition->getElements() as $element) {
|
||||
$handler = $rsv_form_registry->get($element->getType());
|
||||
if ($handler === null) {
|
||||
$result->addError($element->getName(), 'handler_missing', 'No handler registered for element type ' . $element->getType());
|
||||
$this->rollback_all($committed, $submit_id, $data, $result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
if (!$handler->submit($element, $submit_id, $data->getElements(), $result)) {
|
||||
$this->rollback_all($committed, $submit_id, $data, $result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
$committed[] = [$handler, $element];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array{0:RsvFormElementHandler,1:RsvFormElementDefinition}> $committed
|
||||
*/
|
||||
private function rollback_all(array $committed, int $submit_id, RsvFormData $data, RsvFormSubmitResult $result): void {
|
||||
foreach (array_reverse($committed) as [$handler, $element]) {
|
||||
try {
|
||||
$handler->rollback($element, $submit_id, $data->getElements(), $result);
|
||||
} catch (\Throwable $e) {
|
||||
Logger::error($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Reservair\Logger\Logger;
|
||||
|
||||
/**
|
||||
* Handles submitting a form. If any element fails, nothing is persisted.
|
||||
*/
|
||||
class RsvFormSubmission {
|
||||
public function submit(string $formId, array $data) : array {
|
||||
$repo = new RsvFormDefinitionRepository();
|
||||
$row = $repo->get((int) $formId);
|
||||
|
||||
if ($row === null) {
|
||||
return ['success' => false, 'errors' => [['element' => '', 'code' => 'not_found', 'message' => 'Form not found']]];
|
||||
}
|
||||
|
||||
$definition = new RsvFormDefinition($formId, $row['definition']);
|
||||
$form_data = new RsvFormData($data);
|
||||
$processor = new RsvFormProcessor();
|
||||
|
||||
// Persist the submission first so element handlers can link to it.
|
||||
$submit_repo = new RsvFormSubmitRepository();
|
||||
$submit_id = $submit_repo->add((int) $definition->getId(), $data);
|
||||
|
||||
try {
|
||||
$result = $processor->submit($definition, $submit_id, $form_data);
|
||||
} catch (\Throwable $e) {
|
||||
Logger::error($e);
|
||||
$this->discard_submission($submit_repo, $submit_id);
|
||||
return ['success' => false, 'errors' => [['element' => '', 'code' => 'internal_error', 'message' => 'An unexpected error occurred.']]];
|
||||
}
|
||||
|
||||
if ($result->hasErrors()) {
|
||||
$this->discard_submission($submit_repo, $submit_id);
|
||||
return ['success' => false, 'errors' => $result->getErrors()];
|
||||
}
|
||||
|
||||
return ['success' => true, 'submit_id' => $submit_id, 'values' => $result->getValues()];
|
||||
}
|
||||
|
||||
/** Remove a submission whose run failed. */
|
||||
private function discard_submission(RsvFormSubmitRepository $repo, int $submit_id): void {
|
||||
try {
|
||||
$repo->delete($submit_id);
|
||||
} catch (\Throwable $e) {
|
||||
Logger::error($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
class RsvFormSubmitResult {
|
||||
private array $errors = [];
|
||||
private array $values = [];
|
||||
|
||||
public function addError(string $elementName, string $code, string $message): void {
|
||||
$this->errors[] = [
|
||||
'element' => $elementName,
|
||||
'code' => $code,
|
||||
'message' => $message
|
||||
];
|
||||
}
|
||||
|
||||
public function hasErrors(): bool {
|
||||
return count($this->errors) > 0;
|
||||
}
|
||||
|
||||
public function getErrors(): array {
|
||||
return $this->errors;
|
||||
}
|
||||
|
||||
public function setValue(string $name, $value): void {
|
||||
$this->values[$name] = $value;
|
||||
}
|
||||
|
||||
public function getValue(string $name) {
|
||||
return $this->values[$name] ?? null;
|
||||
}
|
||||
|
||||
public function getValues(): array {
|
||||
return $this->values;
|
||||
}
|
||||
|
||||
public function toDto(): array {
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user