(#2) - forms improvements

This commit is contained in:
Martin Slachta
2026-06-12 16:05:14 +02:00
parent 1294a177ae
commit 37bced77f4
17 changed files with 1152 additions and 1129 deletions
+9 -4
View File
@@ -4,12 +4,17 @@
* Contains definitions of the admin menus
*/
function rsv_admin_menu_definition() {
$reservations = new RsvReservationsPage();
$forms = new RsvFormsPage();
$timetable = new RsvTimetablePage();
$google_cal = new RsvGoogleCalendarSettingsPage();
add_menu_page(
'Reservations Settings', // Page title
'Reservations', // Menu title
RsvCapabilities::MANAGE, // Capability
'reservations-settings', // Menu slug
'rsv_reservations_page', // Callback
[$reservations, 'render'], // Callback
'dashicons-calendar', // Icon
20 // Position
);
@@ -20,7 +25,7 @@ function rsv_admin_menu_definition() {
'Forms',
RsvCapabilities::MANAGE,
'forms-settings',
'rsv_forms_page'
[$forms, 'render']
);
add_submenu_page(
@@ -29,7 +34,7 @@ function rsv_admin_menu_definition() {
'Timetables',
RsvCapabilities::MANAGE,
'timetable-settings',
'rsv_timetable_page'
[$timetable, 'render']
);
add_submenu_page(
@@ -38,6 +43,6 @@ function rsv_admin_menu_definition() {
'Google Calendar',
RsvCapabilities::MANAGE,
'rsv-google-calendar',
'rsv_google_calendar_settings_page'
[$google_cal, 'render']
);
}
+15
View File
@@ -0,0 +1,15 @@
<?php
abstract class RsvAdminPage {
final public function render(): void {
if (!current_user_can(RsvCapabilities::MANAGE)) {
return;
}
echo '<div class="wrap">';
$this->render_content();
echo '</div>';
}
abstract protected function render_content(): void;
}
+340 -336
View File
@@ -1,284 +1,42 @@
<?php
use Reservair\Forms\RsvFormBuilder;
use Reservair\Layout\RsvColumnLayout;
// Shared inline script for the elements data grid.
// $elements_with_ids: array of element objects already carrying an 'id' key.
// $next_id: the first integer not yet used as an id.
function rsv_elements_table_script(array $elements_with_ids, int $next_id, string $form_id, array $element_types, array $timetables = []): void {
$elements_json = json_encode($elements_with_ids);
$types_json = json_encode(array_values($element_types));
$timetables_json = json_encode(array_values($timetables));
?>
<script>
const rsv_element_types = <?= $types_json ?>;
const rsv_timetables = <?= $timetables_json ?>;
class RsvFormsPage extends RsvAdminPage {
const RSV_EMAIL_DEFAULTS = {
accepted_subject: <?= json_encode('Rezervace přijata') ?>,
accepted_body: <?= json_encode("<h1>Vaše rezervace byla přijata</h1>\n<p>Vaše rezervace byla schválena. Těšíme se na vás!</p>") ?>,
refused_subject: <?= json_encode('Rezervace zamítnuta') ?>,
refused_body: <?= json_encode("<h1>Vaše rezervace byla zamítnuta</h1>\n<p>Vaše rezervace bohužel nebyla schválena. Zkuste prosím jiný termín.</p>") ?>,
};
const rsv_elements_source = (function(initial_items, next_id_start) {
const items = initial_items;
let next_id = next_id_start;
return {
get_page(skip, limit) {
skip = skip ?? 0;
limit = limit ?? 20;
return Promise.resolve({ total: items.length, data: items.slice(skip, skip + limit) });
},
put(id, data) {
const idx = items.findIndex(e => e.id === id);
if (idx === -1) return Promise.reject(new Error('Element not found'));
// Destructure reservation-specific fields so they don't bleed into extra_attrs.
const { id: _id, name: _n, label: _l, type: _t, desc: _d, required: _r,
price_per_block: _p, email_templates: _et, timetable_id: _ti, ...extra_attrs } = items[idx];
items[idx] = {
...extra_attrs,
id,
name: data.name ?? '',
label: data.label ?? '',
type: data.type ?? 'text',
desc: data.desc ?? '',
required: data.required === 'on',
...(data.type === 'input-text' ? {
validation: data.validation ?? '',
pattern: data.pattern ?? '',
pattern_message: data.pattern_message ?? '',
} : {}),
...(data.type === 'reservation' ? {
timetable_id: data.timetable_id ? parseInt(data.timetable_id) : null,
price_per_block: parseFloat(data.price_per_block ?? '0') || 0,
email_templates: {
on_accepted: {
enabled: !!data.email_accepted_enabled,
subject: data.email_accepted_subject ?? RSV_EMAIL_DEFAULTS.accepted_subject,
body: data.email_accepted_body ?? RSV_EMAIL_DEFAULTS.accepted_body,
},
on_refused: {
enabled: !!data.email_refused_enabled,
subject: data.email_refused_subject ?? RSV_EMAIL_DEFAULTS.refused_subject,
body: data.email_refused_body ?? RSV_EMAIL_DEFAULTS.refused_body,
},
},
} : {}),
};
return Promise.resolve(items[idx]);
},
add() {
const item = { id: next_id++, name: '', label: '', type: 'text', desc: '', required: false };
items.push(item);
return item;
},
move_up(id) {
const idx = items.findIndex(e => e.id === id);
if (idx > 0) [items[idx - 1], items[idx]] = [items[idx], items[idx - 1]];
},
move_down(id) {
const idx = items.findIndex(e => e.id === id);
if (idx !== -1 && idx < items.length - 1) [items[idx], items[idx + 1]] = [items[idx + 1], items[idx]];
},
remove(id) {
const idx = items.findIndex(e => e.id === id);
if (idx !== -1) items.splice(idx, 1);
},
get_all() {
return items.map(({ id, ...rest }) => rest);
},
};
})(<?= $elements_json ?>, <?= $next_id ?>);
function rsv_render_element_inline_form(dt, row, data) {
const builder = RsvInlineFormBuilder.create(rsv_elements_source)
.fieldset('Element', '50%')
.input_text('name', 'Slug', data?.name ?? '')
.input_text('label', 'Label', data?.label ?? '')
.input_select('type', 'Type', rsv_element_types, data?.type ?? rsv_element_types[0])
.fieldset('Options', '50%')
.input_text('desc', 'Description', data?.desc ?? '')
.input_checkbox('required', 'Required', data?.required ?? false);
if ((data?.type ?? rsv_element_types[0]) === 'reservation') {
const accepted = data?.email_templates?.on_accepted ?? {};
const refused = data?.email_templates?.on_refused ?? {};
const timetable_options = [
{ value: '', label: '— none —' },
...rsv_timetables.map(t => ({ value: t.id, label: t.name })),
];
builder
.input_select('timetable_id', 'Timetable', timetable_options, data?.timetable_id ?? '')
.input_number('price_per_block', 'Price per block', data?.price_per_block ?? 0)
.fieldset('Email — accepted', '100%')
.input_checkbox('email_accepted_enabled', 'Send email when accepted', accepted.enabled ?? true)
.input_text('email_accepted_subject', 'Subject', accepted.subject ?? RSV_EMAIL_DEFAULTS.accepted_subject)
.input_textarea('email_accepted_body', 'Body', accepted.body ?? RSV_EMAIL_DEFAULTS.accepted_body)
.fieldset('Email — refused', '100%')
.input_checkbox('email_refused_enabled', 'Send email when refused', refused.enabled ?? true)
.input_text('email_refused_subject', 'Subject', refused.subject ?? RSV_EMAIL_DEFAULTS.refused_subject)
.input_textarea('email_refused_body', 'Body', refused.body ?? RSV_EMAIL_DEFAULTS.refused_body);
protected function render_content(): void {
if (isset($_GET['action']) && $_GET['action'] === 'edit' && isset($_GET['id'])) {
$this->show_edit(intval($_GET['id']));
return;
}
if ((data?.type ?? rsv_element_types[0]) === 'input-text') {
builder
.input_select('validation', 'Validation', [
{ value: '', label: '— none —' },
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone' },
{ value: 'digits', label: 'Digits only' },
{ value: 'pattern', label: 'Custom pattern' },
], data?.validation ?? '')
.input_text('pattern', 'Custom pattern (regex)', data?.pattern ?? '')
.show_if(RsvInlineFormBuilder.match_p('validation', 'pattern'))
.input_text('pattern_message', 'Pattern error message', data?.pattern_message ?? '')
.show_if(RsvInlineFormBuilder.match_p('validation', 'pattern'));
}
const node = builder.build({
id: data?.id,
colspan: 5,
save_label: 'Save',
on_success: () => elements_dt.refresh(),
on_cancel: () => elements_dt.refresh(),
});
// Type swaps whole fieldsets, so re-render the inline form on change.
// Common fields are carried over; type-specific fields reset to stored data.
const type_select = node.querySelector('select[name="type"]');
if (type_select) {
type_select.addEventListener('change', () => {
const current = Object.fromEntries(new FormData(node.querySelector('form')));
const merged = {
...data,
name: current.name ?? data?.name,
label: current.label ?? data?.label,
desc: current.desc ?? data?.desc,
required: 'required' in current,
type: type_select.value,
};
row.replaceChildren(rsv_render_element_inline_form(dt, row, merged));
});
}
return node;
$this->show_list();
}
// The elements data grid only exists on the edit page. Guard it so a missing
// container can't abort the script and strip the form's submit handler.
const elements_table_el = document.getElementById('form_elements_table');
if (elements_table_el) {
var elements_dt = RsvDataGrid.create_data_grid(
elements_table_el,
rsv_elements_source,
{
'name': RsvDataGrid.action_column('Name', false, {
'Edit': RsvDataGrid.edit_action(function(dt, row, data) {
row.classList.add(
'inline-edit-row', 'inline-edit-row-post',
'quick-edit-row', 'quick-edit-row-post',
'inline-edit-post', 'inline-editor'
);
row.replaceChildren(rsv_render_element_inline_form(dt, row, data));
}),
'Move Up': RsvDataGrid.func_action(function(dt, row, data) {
rsv_elements_source.move_up(data.id);
dt.refresh();
}),
'Move Down': RsvDataGrid.func_action(function(dt, row, data) {
rsv_elements_source.move_down(data.id);
dt.refresh();
}),
'Remove': RsvDataGrid.func_action(function(dt, row, data) {
rsv_elements_source.remove(data.id);
dt.refresh();
}),
}),
'label': RsvDataGrid.column('Label', false),
'type': RsvDataGrid.column('Type', false),
'desc': RsvDataGrid.column('Description', false),
'required': RsvDataGrid.column('Required', false),
'details': RsvDataGrid.column('Details', false),
}
);
elements_dt.map_column('details', (dt, row, data) => {
const td = document.createElement('td');
if (data.type === 'reservation') {
const parts = [];
if (data.timetable_id) {
const t = rsv_timetables.find(t => t.id === data.timetable_id);
parts.push(`Timetable: ${t ? t.name : data.timetable_id}`);
}
if (data.price_per_block != null) parts.push(`Price/block: ${data.price_per_block}`);
const et = data.email_templates ?? {};
const emails = [];
if (et.on_accepted?.enabled) emails.push('accepted');
if (et.on_refused?.enabled) emails.push('refused');
if (emails.length) parts.push(`Emails: ${emails.join(', ')}`);
td.innerText = parts.join(' · ');
}
return td;
});
elements_dt.refresh();
document.getElementById('rsv_add_element_btn').onclick = function() {
rsv_elements_source.add();
elements_dt.refresh();
};
}
RsvAdminForm.bind(document.getElementById('<?= $form_id ?>'), {
transform: (body) => ({
name: body.name,
definition: {
email_key: body.definition?.email_key ?? '',
elements: rsv_elements_source.get_all(),
},
}),
refresh: () => { if (typeof forms_dt !== 'undefined') forms_dt.refresh(); },
});
</script>
<?php
}
function rsv_form_info_page(): void {
global $rsv_form_registry;
$element_types = array_keys($rsv_form_registry->handlers);
$elements_with_ids = [];
$next_id = 1;
$timetables = (new RsvTimetableService())->get_all();
?>
<h1>Formuláře</h1>
<hr>
<div id="col-container" class="wp-clearfix">
<div id="col-left">
<div class="col-wrap">
<div class="form-wrap">
<h2>Přidat formulář</h2>
<form id="add_form_definition"
method="post"
data-method="POST"
data-success-msg="Form definition created."
action="<?= get_rest_url(null, 'reservations/v1/form-definition'); ?>">
<?php wp_nonce_field('my_action', 'my_nonce'); ?>
<?php echo RsvFormBuilder::create('add_form_definition')->text('name', 'Název')->render(); ?>
</form>
</div>
private function show_list(): void {
global $rsv_form_registry;
$element_types = array_keys($rsv_form_registry->handlers);
$elements_with_ids = [];
$next_id = 1;
$timetables = (new RsvTimetableService())->get_all();
?>
<h1>Formuláře</h1>
<hr>
<?php
RsvColumnLayout::split('1:2')
->column(function () {
echo RsvFormBuilder::create('add_form_definition', get_rest_url(null, 'reservations/v1/form-definition'), 'POST', 'Form definition created.')
->heading('Přidat formulář')
->nonce('my_action', 'my_nonce')
->text('name', 'Název')
->render();
?>
<hr>
<p class="submit">
<button type="submit" form="add_form_definition" class="button button-primary">Add Form Definition</button>
</p>
</div>
</div>
<div id="col-right">
<div class="col-wrap">
<?php })
->column(function () { ?>
<div id="forms_table"></div>
<script>
var forms_dt = RsvDataGrid.create_data_grid(forms_table,
@@ -289,6 +47,7 @@ function rsv_form_info_page(): void {
`<?= menu_page_url('forms-settings', false) ?>&id=${data.form_id}&action=edit`
),
'Trash': RsvDataGrid.func_action(function(dt, row, data) {
if (!confirm('Delete this form? This cannot be undone.')) return;
dt.resource.delete(data.form_id).then(() => forms_dt.refresh()).catch(err => alert(err.message));
}),
'Clone': RsvDataGrid.func_action(function(dt, row, data) {
@@ -318,80 +77,325 @@ function rsv_form_info_page(): void {
});
forms_dt.refresh();
</script>
</div>
</div>
</div>
<?php })
->output();
?>
<?php rsv_elements_table_script($elements_with_ids, $next_id, 'add_form_definition', $element_types, $timetables); ?>
<?php
}
function rsv_form_definition_edit_page(int $id): void {
global $rsv_form_registry;
$element_types = array_keys($rsv_form_registry->handlers);
$repo = new RsvFormDefinitionRepository();
$form_def = $repo->get($id);
if ($form_def === null) {
echo '<div class="notice notice-error"><p>Form definition not found.</p></div>';
return;
<?php $this->elements_table_script($elements_with_ids, $next_id, 'add_form_definition', $element_types, $timetables); ?>
<?php
}
$definition = $form_def['definition'] ?? [];
$raw_elements = array_values($definition['elements'] ?? []);
private function show_edit(int $id): void {
global $rsv_form_registry;
$element_types = array_keys($rsv_form_registry->handlers);
$elements_with_ids = array_map(function (array $el, int $idx): array {
return array_merge($el, ['id' => $idx + 1]);
}, $raw_elements, array_keys($raw_elements));
$repo = new RsvFormDefinitionRepository();
$form_def = $repo->get($id);
$next_id = count($elements_with_ids) + 1;
$timetables = (new RsvTimetableService())->get_all();
?>
<h1>Edit Form: <?= esc_html($form_def['name']) ?></h1>
<a href="<?= menu_page_url('forms-settings', false) ?>">← Back to Forms</a>
<hr>
<form id="edit_form_definition"
method="post"
data-method="PUT"
data-success-msg="Form definition updated."
action="<?= get_rest_url(null, 'reservations/v1/form-definition/' . $id); ?>">
<?php
echo RsvFormBuilder::create('edit_form_definition')
->text('name', 'Name', '', true, $form_def['name'])
->text('definition.email_key', 'Email Key', "Name of the form field that holds the submitter's email address.", true, $definition['email_key'] ?? '')
->render();
?>
</form>
<hr>
<h2>Form Elements</h2>
<p>Define the fields that will appear in this form.</p>
<div id="form_elements_table"></div>
<p>
<button type="button" class="button" id="rsv_add_element_btn">+ Add Element</button>
</p>
<p class="submit">
<button type="submit" form="edit_form_definition" class="button button-primary">Update Form Definition</button>
</p>
<?php rsv_elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables); ?>
<?php
}
function rsv_forms_page(): void {
if (isset($_GET['action'])) {
if ($_GET['action'] === 'edit' && isset($_GET['id'])) {
rsv_form_definition_edit_page(intval($_GET['id']));
if ($form_def === null) {
echo '<div class="notice notice-error"><p>Form definition not found.</p></div>';
return;
}
$definition = $form_def['definition'] ?? [];
$raw_elements = array_values($definition['elements'] ?? []);
$elements_with_ids = array_map(function (array $el, int $idx): array {
return array_merge($el, ['id' => $idx + 1]);
}, $raw_elements, array_keys($raw_elements));
$next_id = count($elements_with_ids) + 1;
$timetables = (new RsvTimetableService())->get_all();
$email_key_options = ['' => '— select field —'];
foreach ($raw_elements as $el) {
$el_name = $el['name'] ?? '';
if ($el_name === '') {
continue;
}
$el_label = $el['label'] ?? '';
$email_key_options[$el_name] = $el_label !== '' ? "$el_label ($el_name)" : $el_name;
}
?>
<h1>Edit Form: <?= esc_html($form_def['name']) ?></h1>
<a href="<?= menu_page_url('forms-settings', false) ?>">← Back to Forms</a>
<hr>
<?php
echo RsvFormBuilder::create('edit_form_definition', get_rest_url(null, 'reservations/v1/form-definition/' . $id), 'PUT', 'Form definition updated.')
->text('name', 'Name', '', true, $form_def['name'])
->select('definition.email_key', 'Email Key', $email_key_options, "Form field that holds the submitter's email address.", true, $definition['email_key'] ?? '')
->render();
?>
<hr>
<h2>Form Elements</h2>
<p>Define the fields that will appear in this form.</p>
<div id="form_elements_table"></div>
<p>
<button type="button" class="button" id="rsv_add_element_btn">+ Add Element</button>
</p>
<p class="submit">
<button type="submit" form="edit_form_definition" class="button button-primary">Update Form Definition</button>
</p>
<?php $this->elements_table_script($elements_with_ids, $next_id, 'edit_form_definition', $element_types, $timetables); ?>
<?php
}
rsv_form_info_page();
private function elements_table_script(array $elements_with_ids, int $next_id, string $form_id, array $element_types, array $timetables = []): void {
$elements_json = json_encode($elements_with_ids);
$types_json = json_encode(array_values($element_types));
$timetables_json = json_encode(array_values($timetables));
?>
<script>
const rsv_element_types = <?= $types_json ?>;
const rsv_timetables = <?= $timetables_json ?>;
const RSV_EMAIL_DEFAULTS = {
accepted_subject: <?= json_encode('Rezervace přijata') ?>,
accepted_body: <?= json_encode("<h1>Vaše rezervace byla přijata</h1>\n<p>Vaše rezervace byla schválena. Těšíme se na vás!</p>") ?>,
refused_subject: <?= json_encode('Rezervace zamítnuta') ?>,
refused_body: <?= json_encode("<h1>Vaše rezervace byla zamítnuta</h1>\n<p>Vaše rezervace bohužel nebyla schválena. Zkuste prosím jiný termín.</p>") ?>,
};
const rsv_elements_source = (function(initial_items, next_id_start) {
const items = initial_items;
let next_id = next_id_start;
return {
get_page(skip, limit) {
skip = skip ?? 0;
limit = limit ?? 20;
return Promise.resolve({ total: items.length, data: items.slice(skip, skip + limit) });
},
put(id, data) {
const idx = items.findIndex(e => e.id === id);
if (idx === -1) return Promise.reject(new Error('Element not found'));
// Destructure reservation-specific fields so they don't bleed into extra_attrs.
const { id: _id, name: _n, label: _l, type: _t, desc: _d, required: _r,
price_per_block: _p, email_templates: _et, timetable_id: _ti, ...extra_attrs } = items[idx];
items[idx] = {
...extra_attrs,
id,
name: data.name ?? '',
label: data.label ?? '',
type: data.type ?? 'text',
desc: data.desc ?? '',
required: data.required === 'on',
...(data.type === 'input-text' ? {
validation: data.validation ?? '',
pattern: data.pattern ?? '',
pattern_message: data.pattern_message ?? '',
} : {}),
...(data.type === 'reservation' ? {
timetable_id: data.timetable_id ? parseInt(data.timetable_id) : null,
price_per_block: parseFloat(data.price_per_block ?? '0') || 0,
email_templates: {
on_accepted: {
enabled: !!data.email_accepted_enabled,
subject: data.email_accepted_subject ?? RSV_EMAIL_DEFAULTS.accepted_subject,
body: data.email_accepted_body ?? RSV_EMAIL_DEFAULTS.accepted_body,
},
on_refused: {
enabled: !!data.email_refused_enabled,
subject: data.email_refused_subject ?? RSV_EMAIL_DEFAULTS.refused_subject,
body: data.email_refused_body ?? RSV_EMAIL_DEFAULTS.refused_body,
},
},
} : {}),
};
return Promise.resolve(items[idx]);
},
add() {
const item = { id: next_id++, name: '', label: '', type: 'text', desc: '', required: false };
items.push(item);
return item;
},
move_up(id) {
const idx = items.findIndex(e => e.id === id);
if (idx > 0) [items[idx - 1], items[idx]] = [items[idx], items[idx - 1]];
},
move_down(id) {
const idx = items.findIndex(e => e.id === id);
if (idx !== -1 && idx < items.length - 1) [items[idx], items[idx + 1]] = [items[idx + 1], items[idx]];
},
remove(id) {
const idx = items.findIndex(e => e.id === id);
if (idx !== -1) items.splice(idx, 1);
},
get_all() {
return items.map(({ id, ...rest }) => rest);
},
};
})(<?= $elements_json ?>, <?= $next_id ?>);
function rsv_render_element_inline_form(dt, row, data) {
const builder = RsvInlineFormBuilder.create(rsv_elements_source)
.fieldset('Element', '50%')
.input_text('name', 'Slug', data?.name ?? '')
.input_text('label', 'Label', data?.label ?? '')
.input_select('type', 'Type', rsv_element_types, data?.type ?? rsv_element_types[0])
.fieldset('Options', '50%')
.input_text('desc', 'Description', data?.desc ?? '')
.input_checkbox('required', 'Required', data?.required ?? false);
if ((data?.type ?? rsv_element_types[0]) === 'reservation') {
const accepted = data?.email_templates?.on_accepted ?? {};
const refused = data?.email_templates?.on_refused ?? {};
const timetable_options = [
{ value: '', label: '— none —' },
...rsv_timetables.map(t => ({ value: t.id, label: t.name })),
];
builder
.input_select('timetable_id', 'Timetable', timetable_options, data?.timetable_id ?? '')
.input_number('price_per_block', 'Price per block', data?.price_per_block ?? 0)
.fieldset('Email — accepted', '100%')
.input_checkbox('email_accepted_enabled', 'Send email when accepted', accepted.enabled ?? true)
.input_text('email_accepted_subject', 'Subject', accepted.subject ?? RSV_EMAIL_DEFAULTS.accepted_subject)
.input_textarea('email_accepted_body', 'Body', accepted.body ?? RSV_EMAIL_DEFAULTS.accepted_body)
.fieldset('Email — refused', '100%')
.input_checkbox('email_refused_enabled', 'Send email when refused', refused.enabled ?? true)
.input_text('email_refused_subject', 'Subject', refused.subject ?? RSV_EMAIL_DEFAULTS.refused_subject)
.input_textarea('email_refused_body', 'Body', refused.body ?? RSV_EMAIL_DEFAULTS.refused_body);
}
if ((data?.type ?? rsv_element_types[0]) === 'input-text') {
builder
.input_select('validation', 'Validation', [
{ value: '', label: '— none —' },
{ value: 'email', label: 'Email' },
{ value: 'phone', label: 'Phone' },
{ value: 'digits', label: 'Digits only' },
{ value: 'pattern', label: 'Custom pattern' },
], data?.validation ?? '')
.input_text('pattern', 'Custom pattern (regex)', data?.pattern ?? '')
.show_if(RsvInlineFormBuilder.match_p('validation', 'pattern'))
.input_text('pattern_message', 'Pattern error message', data?.pattern_message ?? '')
.show_if(RsvInlineFormBuilder.match_p('validation', 'pattern'));
}
const node = builder.build({
id: data?.id,
colspan: 6,
save_label: 'Save',
on_success: () => elements_dt.refresh(),
on_cancel: () => elements_dt.refresh(),
});
// Type swaps whole fieldsets, so re-render the inline form on change.
// Common fields are carried over; type-specific fields reset to stored data.
const type_select = node.querySelector('select[name="type"]');
if (type_select) {
type_select.addEventListener('change', () => {
const current = Object.fromEntries(new FormData(node.querySelector('form')));
const merged = {
...data,
name: current.name ?? data?.name,
label: current.label ?? data?.label,
desc: current.desc ?? data?.desc,
required: 'required' in current,
type: type_select.value,
};
row.replaceChildren(rsv_render_element_inline_form(dt, row, merged));
});
}
return node;
}
// The elements data grid only exists on the edit page. Guard it so a missing
// container can't abort the script and strip the form's submit handler.
const elements_table_el = document.getElementById('form_elements_table');
if (elements_table_el) {
var elements_dt = RsvDataGrid.create_data_grid(
elements_table_el,
rsv_elements_source,
{
'name': RsvDataGrid.action_column('Name', false, {
'Edit': RsvDataGrid.edit_action(function(dt, row, data) {
row.classList.add(
'inline-edit-row', 'inline-edit-row-post',
'quick-edit-row', 'quick-edit-row-post',
'inline-edit-post', 'inline-editor'
);
row.replaceChildren(rsv_render_element_inline_form(dt, row, data));
}),
'Move Up': RsvDataGrid.func_action(function(dt, row, data) {
rsv_elements_source.move_up(data.id);
dt.refresh();
}),
'Move Down': RsvDataGrid.func_action(function(dt, row, data) {
rsv_elements_source.move_down(data.id);
dt.refresh();
}),
'Remove': RsvDataGrid.func_action(function(dt, row, data) {
rsv_elements_source.remove(data.id);
dt.refresh();
}),
}),
'label': RsvDataGrid.column('Label', false),
'type': RsvDataGrid.column('Type', false),
'desc': RsvDataGrid.column('Description', false),
'required': RsvDataGrid.column('Required', false),
'details': RsvDataGrid.column('Details', false),
}
);
elements_dt.map_column('details', (dt, row, data) => {
const td = document.createElement('td');
if (data.type === 'reservation') {
const parts = [];
if (data.timetable_id) {
const t = rsv_timetables.find(t => t.id === data.timetable_id);
parts.push(`Timetable: ${t ? t.name : data.timetable_id}`);
}
if (data.price_per_block != null) parts.push(`Price/block: ${data.price_per_block}`);
const et = data.email_templates ?? {};
const emails = [];
if (et.on_accepted?.enabled) emails.push('accepted');
if (et.on_refused?.enabled) emails.push('refused');
if (emails.length) parts.push(`Emails: ${emails.join(', ')}`);
td.innerText = parts.join(' · ');
}
return td;
});
elements_dt.refresh();
document.getElementById('rsv_add_element_btn').onclick = function() {
rsv_elements_source.add();
elements_dt.refresh();
};
}
// The Email Key select offers the form's fields. Elements are edited in
// the grid above and only persisted on save, so refresh the options from
// the live source each time the select is opened.
const rsv_email_key_select = document.querySelector('#edit_form_definition select[name="definition.email_key"]');
function rsv_sync_email_key_options() {
if (!rsv_email_key_select) return;
const selected = rsv_email_key_select.value;
const options = [new Option('— select field —', '')];
for (const el of rsv_elements_source.get_all()) {
if (!el.name) continue;
options.push(new Option(el.label ? `${el.label} (${el.name})` : el.name, el.name));
}
rsv_email_key_select.replaceChildren(...options);
rsv_email_key_select.value = selected;
}
rsv_email_key_select?.addEventListener('focus', rsv_sync_email_key_options);
RsvAdminForm.bind(document.getElementById('<?= $form_id ?>'), {
transform: (body) => ({
name: body.name,
definition: {
email_key: body.definition?.email_key ?? '',
elements: rsv_elements_source.get_all(),
},
}),
refresh: () => { if (typeof forms_dt !== 'undefined') forms_dt.refresh(); },
});
</script>
<?php
}
}
@@ -1,56 +1,53 @@
<?php
function rsv_google_calendar_settings_page(): void {
if (!current_user_can(RsvCapabilities::MANAGE)) {
return;
}
class RsvGoogleCalendarSettingsPage extends RsvAdminPage {
$service = new RsvGoogleCalendarService();
$notice = null;
protected function render_content(): void {
$service = new RsvGoogleCalendarService();
$notice = null;
if (isset($_GET['connected'])) {
$notice = ['type' => 'success', 'message' => 'Google Calendar connected successfully.'];
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['rsv_google_settings_nonce'])) {
if (!wp_verify_nonce($_POST['rsv_google_settings_nonce'], 'rsv_google_settings')) {
wp_die('Security check failed.');
if (isset($_GET['connected'])) {
$notice = ['type' => 'success', 'message' => 'Google Calendar connected successfully.'];
}
if (isset($_POST['rsv_disconnect'])) {
$service->disconnect();
$notice = ['type' => 'success', 'message' => 'Disconnected from Google Calendar.'];
} elseif (isset($_POST['rsv_register_webhook'])) {
$result = $service->register_webhook();
$notice = isset($result['id'])
? ['type' => 'success', 'message' => 'Webhook registered.']
: ['type' => 'error', 'message' => 'Webhook registration failed: ' . ($result['error'] ?? json_encode($result))];
} elseif (isset($_POST['rsv_stop_webhook'])) {
$service->stop_webhook();
$notice = ['type' => 'success', 'message' => 'Webhook stopped.'];
} else {
update_option('rsv_google_client_id', sanitize_text_field($_POST['rsv_google_client_id'] ?? ''));
update_option('rsv_google_calendar_id', sanitize_text_field($_POST['rsv_google_calendar_id'] ?? 'primary'));
// Only overwrite the (encrypted) client secret when a new value is
// supplied — the field renders blank, so otherwise saving any other
// setting would wipe the stored secret.
$client_secret = sanitize_text_field($_POST['rsv_google_client_secret'] ?? '');
if ($client_secret !== '') {
$service->set_client_secret($client_secret);
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['rsv_google_settings_nonce'])) {
if (!wp_verify_nonce($_POST['rsv_google_settings_nonce'], 'rsv_google_settings')) {
wp_die('Security check failed.');
}
$notice = ['type' => 'success', 'message' => 'Settings saved.'];
}
}
$connected = $service->is_google_connected();
$webhook_registered = $service->is_webhook_registered();
$webhook_expiry = (int) get_option('rsv_google_webhook_expiration', 0);
$client_id = esc_attr(get_option('rsv_google_client_id', ''));
$cal_id = esc_attr(get_option('rsv_google_calendar_id', 'primary'));
$oauth_url = esc_url($service->get_oauth_url());
?>
<div class="wrap">
if (isset($_POST['rsv_disconnect'])) {
$service->disconnect();
$notice = ['type' => 'success', 'message' => 'Disconnected from Google Calendar.'];
} elseif (isset($_POST['rsv_register_webhook'])) {
$result = $service->register_webhook();
$notice = isset($result['id'])
? ['type' => 'success', 'message' => 'Webhook registered.']
: ['type' => 'error', 'message' => 'Webhook registration failed: ' . ($result['error'] ?? json_encode($result))];
} elseif (isset($_POST['rsv_stop_webhook'])) {
$service->stop_webhook();
$notice = ['type' => 'success', 'message' => 'Webhook stopped.'];
} else {
update_option('rsv_google_client_id', sanitize_text_field($_POST['rsv_google_client_id'] ?? ''));
update_option('rsv_google_calendar_id', sanitize_text_field($_POST['rsv_google_calendar_id'] ?? 'primary'));
// Only overwrite the (encrypted) client secret when a new value is
// supplied — the field renders blank, so otherwise saving any other
// setting would wipe the stored secret.
$client_secret = sanitize_text_field($_POST['rsv_google_client_secret'] ?? '');
if ($client_secret !== '') {
$service->set_client_secret($client_secret);
}
$notice = ['type' => 'success', 'message' => 'Settings saved.'];
}
}
$connected = $service->is_google_connected();
$webhook_registered = $service->is_webhook_registered();
$webhook_expiry = (int) get_option('rsv_google_webhook_expiration', 0);
$client_id = esc_attr(get_option('rsv_google_client_id', ''));
$cal_id = esc_attr(get_option('rsv_google_calendar_id', 'primary'));
$oauth_url = esc_url($service->get_oauth_url());
?>
<h1>Google Calendar</h1>
<?php if ($notice): ?>
@@ -127,6 +124,6 @@ function rsv_google_calendar_settings_page(): void {
<?php endif; ?>
<?php endif; ?>
</form>
</div>
<?php
<?php
}
}
+206 -203
View File
@@ -1,219 +1,222 @@
<?php
function rsv_reservations_page(): void {
?>
<h1>Form Submissions</h1>
class RsvReservationsPage extends RsvAdminPage {
<hr>
<div id="reservations_table"></div>
protected function render_content(): void {
?>
<h1>Form Submissions</h1>
<script>
function rsv_fmt_utc(utc_str) {
if (!utc_str) return '';
return new Date(utc_str.replace(' ', 'T') + 'Z').toLocaleString();
}
<hr>
<div id="reservations_table"></div>
function rsv_cell_value(value) {
if (value === null || value === undefined) return '';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
function rsv_make_table(head_labels, rows_data, cell_fn) {
const table = document.createElement('table');
table.classList.add('wp-list-table', 'widefat', 'fixed', 'striped', 'rsv-detail-table');
const thead = document.createElement('thead');
const header_row = document.createElement('tr');
for (const label of head_labels) {
const th = document.createElement('th');
th.textContent = label;
header_row.appendChild(th);
<script>
function rsv_fmt_utc(utc_str) {
if (!utc_str) return '';
return new Date(utc_str.replace(' ', 'T') + 'Z').toLocaleString();
}
thead.appendChild(header_row);
table.appendChild(thead);
const tbody = document.createElement('tbody');
for (const row_data of rows_data) {
const tr = document.createElement('tr');
for (const cell of cell_fn(row_data)) {
const td = document.createElement('td');
td.textContent = cell;
tr.appendChild(td);
function rsv_cell_value(value) {
if (value === null || value === undefined) return '';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
function rsv_make_table(head_labels, rows_data, cell_fn) {
const table = document.createElement('table');
table.classList.add('wp-list-table', 'widefat', 'fixed', 'striped', 'rsv-detail-table');
const thead = document.createElement('thead');
const header_row = document.createElement('tr');
for (const label of head_labels) {
const th = document.createElement('th');
th.textContent = label;
header_row.appendChild(th);
}
tbody.appendChild(tr);
}
table.appendChild(tbody);
thead.appendChild(header_row);
table.appendChild(thead);
return table;
}
function rsv_flatten_form_entries(obj, depth) {
const rows = [];
for (const [key, val] of Object.entries(obj)) {
if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
rows.push({ key, value: null, depth });
for (const child of rsv_flatten_form_entries(val, depth + 1)) rows.push(child);
} else if (Array.isArray(val)) {
rows.push({ key, value: null, depth });
val.forEach((item, i) => {
if (item !== null && typeof item === 'object') {
rows.push({ key: `[${i}]`, value: null, depth: depth + 1 });
for (const child of rsv_flatten_form_entries(item, depth + 2)) rows.push(child);
} else {
rows.push({ key: `[${i}]`, value: rsv_cell_value(item), depth: depth + 1 });
}
});
} else {
rows.push({ key, value: rsv_cell_value(val), depth });
const tbody = document.createElement('tbody');
for (const row_data of rows_data) {
const tr = document.createElement('tr');
for (const cell of cell_fn(row_data)) {
const td = document.createElement('td');
td.textContent = cell;
tr.appendChild(td);
}
tbody.appendChild(tr);
}
}
return rows;
}
table.appendChild(tbody);
function rsv_make_form_table(form_values) {
const table = document.createElement('table');
table.classList.add('wp-list-table', 'widefat', 'fixed', 'striped', 'rsv-detail-table');
const thead = document.createElement('thead');
const header_row = document.createElement('tr');
for (const label of ['Field', 'Value']) {
const th = document.createElement('th');
th.textContent = label;
header_row.appendChild(th);
}
thead.appendChild(header_row);
table.appendChild(thead);
const tbody = document.createElement('tbody');
for (const { key, value, depth } of rsv_flatten_form_entries(form_values, 0)) {
const tr = document.createElement('tr');
const td_key = document.createElement('td');
td_key.textContent = key;
td_key.classList.add('rsv-form-key');
td_key.style.setProperty('--rsv-depth', depth);
if (value === null) td_key.classList.add('rsv-form-key--group');
const td_val = document.createElement('td');
td_val.textContent = value ?? '';
if (value === null) td_val.classList.add('rsv-form-val--null');
tr.appendChild(td_key);
tr.appendChild(td_val);
tbody.appendChild(tr);
}
table.appendChild(tbody);
return table;
}
function rsv_render_reservation_detail(dt, row, data, detail) {
const td = document.createElement('td');
td.setAttribute('colspan', 3);
td.classList.add('rsv-detail-expand');
const form_heading = document.createElement('h4');
form_heading.textContent = 'Form Submission';
form_heading.classList.add('rsv-detail-heading');
const form_values = detail.form_values ?? {};
let form_content;
if (Object.keys(form_values).length === 0) {
form_content = document.createElement('p');
form_content.textContent = 'No form values recorded.';
form_content.classList.add('rsv-detail-empty');
} else {
form_content = rsv_make_form_table(form_values);
return table;
}
const timetable_heading = document.createElement('h4');
timetable_heading.textContent = 'Timetable Reservations';
timetable_heading.classList.add('rsv-detail-heading');
const timetable_rows = detail.timetable_reservations ?? [];
let timetable_content;
if (timetable_rows.length === 0) {
timetable_content = document.createElement('p');
timetable_content.textContent = 'No timetable reservations.';
timetable_content.classList.add('rsv-detail-empty');
} else {
timetable_content = rsv_make_table(
['ID', 'Timetable', 'Start', 'End'],
timetable_rows,
r => [r.id, r.timetable_id, rsv_fmt_utc(r.start), rsv_fmt_utc(r.end)]
);
}
const actions = document.createElement('div');
actions.classList.add('rsv-detail-actions');
const close_btn = document.createElement('button');
close_btn.classList.add('button');
close_btn.textContent = 'Close';
close_btn.onclick = () => dt.refresh_row(row, data);
actions.appendChild(close_btn);
if (detail.pending_confirmation) {
const base_url = `<?= get_rest_url(null, 'reservations/v1/reservation'); ?>/${data.id}`;
const accept_btn = document.createElement('button');
accept_btn.classList.add('button', 'button-primary');
accept_btn.textContent = 'Accept';
accept_btn.onclick = () => {
accept_btn.disabled = true;
refuse_btn.disabled = true;
fetch(base_url + '/accept', { method: 'POST', credentials: 'same-origin' })
.then(() => dt.refresh())
.catch(() => { accept_btn.disabled = false; refuse_btn.disabled = false; });
};
const refuse_btn = document.createElement('button');
refuse_btn.classList.add('button', 'button-secondary', 'rsv-btn-refuse');
refuse_btn.textContent = 'Refuse';
refuse_btn.onclick = () => {
accept_btn.disabled = true;
refuse_btn.disabled = true;
fetch(base_url + '/refuse', { method: 'POST', credentials: 'same-origin' })
.then(() => dt.refresh())
.catch(() => { accept_btn.disabled = false; refuse_btn.disabled = false; });
};
actions.appendChild(accept_btn);
actions.appendChild(refuse_btn);
}
td.replaceChildren(form_heading, form_content, timetable_heading, timetable_content, actions);
return td;
}
var reservations_dt = RsvDataGrid.create_data_grid(
document.getElementById('reservations_table'),
RsvReservationResource(),
{
'id': RsvDataGrid.action_column('ID', false, {
'View': RsvDataGrid.func_action(function(dt, row, data) {
const url = `<?= get_rest_url(null, 'reservations/v1/reservation'); ?>/${data.id}`;
fetch(url, {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
})
.then(r => r.json())
.then(detail => {
row.classList.add(
'inline-edit-row', 'inline-edit-row-post',
'quick-edit-row', 'quick-edit-row-post',
'inline-edit-post', 'inline-editor'
);
row.replaceChildren(rsv_render_reservation_detail(dt, row, data, detail));
function rsv_flatten_form_entries(obj, depth) {
const rows = [];
for (const [key, val] of Object.entries(obj)) {
if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
rows.push({ key, value: null, depth });
for (const child of rsv_flatten_form_entries(val, depth + 1)) rows.push(child);
} else if (Array.isArray(val)) {
rows.push({ key, value: null, depth });
val.forEach((item, i) => {
if (item !== null && typeof item === 'object') {
rows.push({ key: `[${i}]`, value: null, depth: depth + 1 });
for (const child of rsv_flatten_form_entries(item, depth + 2)) rows.push(child);
} else {
rows.push({ key: `[${i}]`, value: rsv_cell_value(item), depth: depth + 1 });
}
});
}),
}),
'form_submit_id': RsvDataGrid.column('Form Submit', false),
'is_confirmed': RsvDataGrid.column('Confirmed', false),
} else {
rows.push({ key, value: rsv_cell_value(val), depth });
}
}
return rows;
}
);
reservations_dt.refresh();
</script>
<?php
function rsv_make_form_table(form_values) {
const table = document.createElement('table');
table.classList.add('wp-list-table', 'widefat', 'fixed', 'striped', 'rsv-detail-table');
const thead = document.createElement('thead');
const header_row = document.createElement('tr');
for (const label of ['Field', 'Value']) {
const th = document.createElement('th');
th.textContent = label;
header_row.appendChild(th);
}
thead.appendChild(header_row);
table.appendChild(thead);
const tbody = document.createElement('tbody');
for (const { key, value, depth } of rsv_flatten_form_entries(form_values, 0)) {
const tr = document.createElement('tr');
const td_key = document.createElement('td');
td_key.textContent = key;
td_key.classList.add('rsv-form-key');
td_key.style.setProperty('--rsv-depth', depth);
if (value === null) td_key.classList.add('rsv-form-key--group');
const td_val = document.createElement('td');
td_val.textContent = value ?? '';
if (value === null) td_val.classList.add('rsv-form-val--null');
tr.appendChild(td_key);
tr.appendChild(td_val);
tbody.appendChild(tr);
}
table.appendChild(tbody);
return table;
}
function rsv_render_reservation_detail(dt, row, data, detail) {
const td = document.createElement('td');
td.setAttribute('colspan', 3);
td.classList.add('rsv-detail-expand');
const form_heading = document.createElement('h4');
form_heading.textContent = 'Form Submission';
form_heading.classList.add('rsv-detail-heading');
const form_values = detail.form_values ?? {};
let form_content;
if (Object.keys(form_values).length === 0) {
form_content = document.createElement('p');
form_content.textContent = 'No form values recorded.';
form_content.classList.add('rsv-detail-empty');
} else {
form_content = rsv_make_form_table(form_values);
}
const timetable_heading = document.createElement('h4');
timetable_heading.textContent = 'Timetable Reservations';
timetable_heading.classList.add('rsv-detail-heading');
const timetable_rows = detail.timetable_reservations ?? [];
let timetable_content;
if (timetable_rows.length === 0) {
timetable_content = document.createElement('p');
timetable_content.textContent = 'No timetable reservations.';
timetable_content.classList.add('rsv-detail-empty');
} else {
timetable_content = rsv_make_table(
['ID', 'Timetable', 'Start', 'End'],
timetable_rows,
r => [r.id, r.timetable_id, rsv_fmt_utc(r.start), rsv_fmt_utc(r.end)]
);
}
const actions = document.createElement('div');
actions.classList.add('rsv-detail-actions');
const close_btn = document.createElement('button');
close_btn.classList.add('button');
close_btn.textContent = 'Close';
close_btn.onclick = () => dt.refresh_row(row, data);
actions.appendChild(close_btn);
if (detail.pending_confirmation) {
const base_url = `<?= get_rest_url(null, 'reservations/v1/reservation'); ?>/${data.id}`;
const accept_btn = document.createElement('button');
accept_btn.classList.add('button', 'button-primary');
accept_btn.textContent = 'Accept';
accept_btn.onclick = () => {
accept_btn.disabled = true;
refuse_btn.disabled = true;
fetch(base_url + '/accept', { method: 'POST', credentials: 'same-origin' })
.then(() => dt.refresh())
.catch(() => { accept_btn.disabled = false; refuse_btn.disabled = false; });
};
const refuse_btn = document.createElement('button');
refuse_btn.classList.add('button', 'button-secondary', 'rsv-btn-refuse');
refuse_btn.textContent = 'Refuse';
refuse_btn.onclick = () => {
accept_btn.disabled = true;
refuse_btn.disabled = true;
fetch(base_url + '/refuse', { method: 'POST', credentials: 'same-origin' })
.then(() => dt.refresh())
.catch(() => { accept_btn.disabled = false; refuse_btn.disabled = false; });
};
actions.appendChild(accept_btn);
actions.appendChild(refuse_btn);
}
td.replaceChildren(form_heading, form_content, timetable_heading, timetable_content, actions);
return td;
}
var reservations_dt = RsvDataGrid.create_data_grid(
document.getElementById('reservations_table'),
RsvReservationResource(),
{
'id': RsvDataGrid.action_column('ID', false, {
'View': RsvDataGrid.func_action(function(dt, row, data) {
const url = `<?= get_rest_url(null, 'reservations/v1/reservation'); ?>/${data.id}`;
fetch(url, {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
})
.then(r => r.json())
.then(detail => {
row.classList.add(
'inline-edit-row', 'inline-edit-row-post',
'quick-edit-row', 'quick-edit-row-post',
'inline-edit-post', 'inline-editor'
);
row.replaceChildren(rsv_render_reservation_detail(dt, row, data, detail));
});
}),
}),
'form_submit_id': RsvDataGrid.column('Form Submit', false),
'is_confirmed': RsvDataGrid.column('Confirmed', false),
}
);
reservations_dt.refresh();
</script>
<?php
}
}
+315 -375
View File
@@ -1,71 +1,59 @@
<?php
use Reservair\Forms\RsvFormBuilder;
use Reservair\Layout\RsvColumnLayout;
class RsvTimetablePage extends RsvAdminPage {
function rsv_timetable_list_page() {
?>
<style>
/*#col-left {
width: 30%;
}*/
protected function render_content(): void {
if (isset($_GET['action'])) {
if ($_GET['action'] === 'view' && isset($_GET['id'])) {
$this->show_view(intval($_GET['id']));
return;
} elseif ($_GET['action'] === 'edit' && isset($_GET['id'])) {
$this->show_capacity(intval($_GET['id']));
return;
}
// Deletion is intentionally not handled here: a state-changing GET is
// CSRF-prone. Timetables are deleted via the nonce-authenticated REST
// DELETE /timetable/{id} (see the "Trash" action in the list grid).
}
/*#col-right {
width: 70%;
}*/
</style>
<h1>Timetables</h1>
<hr>
<div id="col-container" class="wp-clearfix">
<div id="col-left">
<div class="col-wrap">
<div class="form-wrap">
<h2>Add timetable</h2>
$this->show_list();
}
<?php
$timetable_service = new RsvTimetableService();
$existing_emails = $timetable_service->get_all_maintainer_emails();
$existing_emails_json = json_encode($existing_emails);
?>
<datalist id="maintainer_email_suggestions">
<?php foreach ($existing_emails as $email): ?>
<option value="<?= esc_attr($email) ?>">
<?php endforeach; ?>
</datalist>
private function show_list(): void {
?>
<h1>Timetables</h1>
<hr>
<?php
$timetable_service = new RsvTimetableService();
$existing_emails = $timetable_service->get_all_maintainer_emails();
<form id="add_timetable_form"
method="post"
data-method="POST"
data-success-msg="Timetable created."
action="<?= get_rest_url(null, 'reservations/v1/timetable'); ?>">
<?php
echo RsvFormBuilder::create('add_timetable_form')
->text('name', 'Name', 'Name of the timetable that can be reserved.', true)
->number('block_size', 'Block length (minutes)', 'Duration of one reservable time block in minutes.', true, '', 1)
->email('maintainer_email', 'Maintainer Email', 'Email address to notify when a reservation requires confirmation.', false, '', 'maintainer_email_suggestions')
->submit('Add Timetable')
->render();
?>
</form>
<script>
RsvAdminForm.bind(add_timetable_form, {
transform: (body) => ({
name: body.name,
block_size: parseInt(body.block_size),
maintainer_email: body.maintainer_email || null,
}),
refresh: () => availability_dt.refresh(),
});
</script>
</div>
</div>
</div>
<div id="col-right">
<div class="col-wrap">
<div id="availability_table">
</div>
RsvColumnLayout::split('1:2')
->column(function () use ($existing_emails) {
echo RsvFormBuilder::create('add_timetable_form', get_rest_url(null, 'reservations/v1/timetable'), 'POST', 'Timetable created.')
->heading('Add timetable')
->datalist('maintainer_email_suggestions', $existing_emails)
->text('name', 'Name', 'Name of the timetable that can be reserved.', true)
->number('block_size', 'Block length (minutes)', 'Duration of one reservable time block in minutes.', true, '', 1)
->email('maintainer_email', 'Maintainer Email', 'Email address to notify when a reservation requires confirmation.', false, '', 'maintainer_email_suggestions')
->submit('Add Timetable')
->render();
?>
<script>
RsvAdminForm.bind(add_timetable_form, {
transform: (body) => ({
name: body.name,
block_size: parseInt(body.block_size),
maintainer_email: body.maintainer_email || null,
}),
refresh: () => availability_dt.refresh(),
});
</script>
<?php })
->column(function () { ?>
<div id="availability_table"></div>
<script>
var availability_dt = RsvDataGrid.create_data_grid(availability_table,
RsvTimetableResource(), {
@@ -83,94 +71,79 @@ function rsv_timetable_list_page() {
});
availability_dt.refresh();
</script>
</div>
</div>
</div>
<?php
}
<?php })
->output();
?>
<?php
}
function rsv_create_capacity_form($timetable_id) {
$form = RsvFormBuilder::create("create_capacity_form", get_rest_url(null, 'reservations/v1/timetable/' . $timetable_id . '/capacity'));
$form->date('date', 'First Date', 'Od kterého datumu platí tato kapacita.', true, new DateTime()->format('Y-m-d'));
$form->group('Availability Range', fn($g) => $g
->time('start_time', 'Start')
->time('end_time', 'End')
);
$form->number('capacity', 'Capacity', 'How many reservations can overlap on the same time.', true, 1, 1);
$form->number('min_lead_time_minutes', 'Minimum lead time (minutes)', 'How many minutes in advance must be the reservation created. This is useful if it takes some time to prepare the reservation.', true);
$form->checkbox('requires_confirmation', 'Requires Confirmation?', 'If checked, all the reservations that overlap this capacity will require confirmation from maintainer. The maintainer will receive an email asking for the confirmation.', true);
$form->custom('Is Repeating Event', function() {
return '
<input id="is_repeating" class="regular-text" type="checkbox" name="is_repeating" checked="true">
<p>If the capacity is available repeatingly. For example: repeat each monday every week.</p>
';
});
$form->number('repeat_period_in_days', 'Repeat Period (days)', 'How many days between each repetition.', true);
$form->custom('Apply to Days', function() {
return '
<table class="option-table">
<tbody>
<tr class="form-day-names">
<td>Monday</td>
<td>Tuesday</td>
<td>Wednesday</td>
<td>Thursday</td>
<td>Friday</td>
<td>Saturday</td>
<td>Sunday</td>
</tr>
<tr class="form-days">
<td><input class="is-repeating-input" type="checkbox" name="monday"></td>
<td><input class="is-repeating-input" type="checkbox" name="tuesday"></td>
<td><input class="is-repeating-input" type="checkbox" name="wednesday"></td>
<td><input class="is-repeating-input" type="checkbox" name="thursday"></td>
<td><input class="is-repeating-input" type="checkbox" name="friday"></td>
<td><input class="is-repeating-input" type="checkbox" name="saturday"></td>
<td><input class="is-repeating-input" type="checkbox" name="sunday"></td>
</tr>
</tbody>
</table>';
});
$form->submit('Create Capacity', 'button-primary', 'submit');
private function show_view(int $id): void {
$timetable = (new RsvTimetableService())->get($id);
if ($timetable === null) {
echo '<div class="notice notice-error"><p>Timetable not found.</p></div>';
return;
}
?>
<h1><?= esc_html($timetable->name) ?></h1>
<a href="<?= esc_url(menu_page_url('timetable-settings', false)) ?>">← Back to Timetables</a>
<hr>
?>
<form
id="create_capacity_form"
data-method="POST"
data-success-msg="Capacity created."
action="<?= get_rest_url(null, 'reservations/v1/timetable/' . $timetable_id . '/capacity'); ?>" >
<?php
$form->output();
<table class="form-table">
<tr><th>Name</th><td><?= esc_html($timetable->name) ?></td></tr>
<tr><th>Block size</th><td><?= esc_html($timetable->block_size) ?> minutes</td></tr>
<?php if ($timetable->maintainer_email): ?>
<tr><th>Maintainer email</th><td><?= esc_html($timetable->maintainer_email) ?></td></tr>
<?php endif; ?>
</table>
?>
</form>
<?php
}
<h2>Reservations</h2>
<div id="timetable_reservations_table"></div>
<script>
RsvDataGrid.create_data_grid(
timetable_reservations_table,
RsvTimetableReservationResource(<?= $id ?>),
{
'id': RsvDataGrid.column('ID', false),
'reservation_id': RsvDataGrid.action_column('Reservation', false, {
'Accept': RsvDataGrid.func_action(
(dt, row, data) => RsvReservationClient.accept(data.reservation_id).then(() => dt.refresh()),
(item) => item.pending_confirmation_id !== null
),
'Refuse': RsvDataGrid.func_action(
(dt, row, data) => RsvReservationClient.refuse(data.reservation_id).then(() => dt.refresh()),
(item) => item.pending_confirmation_id !== null
),
}),
'start_utc': RsvDataGrid.column('Start', false),
'end_utc': RsvDataGrid.column('End', false),
'is_confirmed': RsvDataGrid.column('Status', false),
}
)
.map_column('is_confirmed', (dt, row, data) => {
const td = document.createElement('td');
td.textContent = data.pending_confirmation_id !== null ? 'Pending'
: data.is_confirmed == 1 ? 'Confirmed'
: 'Refused';
return td;
})
.refresh();
</script>
<?php
}
function rsv_timetable_capacity_view($id) {
$timetable_service = new RsvTimetableService();
$timetable = $timetable_service->get($id);
$gcal_service = new RsvGoogleCalendarService();
$gcal_connected = $gcal_service->is_google_connected();
$current_calendar_id = $timetable->google_calendar_id ?? null;
$existing_emails = $timetable_service->get_all_maintainer_emails();
?>
<h2>Settings</h2>
<datalist id="maintainer_email_list">
<?php foreach ($existing_emails as $email): ?>
<option value="<?= esc_attr($email) ?>">
<?php endforeach; ?>
</datalist>
<form id="timetable_settings_form"
action="<?= esc_url(get_rest_url(null, 'reservations/v1/timetable/' . $id)) ?>"
data-method="PATCH"
data-success-msg="Settings saved.">
private function show_capacity(int $id): void {
$timetable_service = new RsvTimetableService();
$timetable = $timetable_service->get($id);
$gcal_service = new RsvGoogleCalendarService();
$gcal_connected = $gcal_service->is_google_connected();
$current_calendar_id = $timetable->google_calendar_id ?? null;
$existing_emails = $timetable_service->get_all_maintainer_emails();
?>
<?php
RsvFormBuilder::create("timetable_settings_form")
RsvFormBuilder::create('timetable_settings_form', get_rest_url(null, 'reservations/v1/timetable/' . $id), 'PATCH', 'Settings saved.')
->heading('Settings')
->datalist('maintainer_email_suggestions', $existing_emails)
->text('name', 'Name', 'Name of the timetable that can be reserved.', true, $timetable->name)
->number('block_size', 'Block length (minutes)', 'Duration of one reservable time block in minutes.', true, $timetable->block_size, 1)
->email('maintainer_email', 'Maintainer Email', 'Email address to notify when a reservation requires confirmation.', false, $timetable->maintainer_email ?? '', 'maintainer_email_suggestions')
@@ -189,246 +162,213 @@ function rsv_timetable_capacity_view($id) {
->submit('Save Settings', 'button-primary', 'submit')
->output();
?>
</form>
<script>
(function() {
const gcalSelect = document.getElementById('gcal_select');
const currentCalId = <?= json_encode($current_calendar_id) ?>;
<script>
(function() {
const gcalSelect = document.getElementById('gcal_select');
const currentCalId = <?= json_encode($current_calendar_id) ?>;
if (gcalSelect) {
fetch('<?= esc_js(get_rest_url(null, 'reservations/v1/google-calendars')) ?>', {
credentials: 'same-origin',
headers: { 'X-WP-Nonce': ReservairServiceAPI.nonce },
})
.then(r => r.json())
.then(calendars => {
for (const cal of calendars) {
const opt = document.createElement('option');
opt.value = cal.id;
opt.textContent = cal.summary;
if (cal.id === currentCalId) opt.selected = true;
gcalSelect.appendChild(opt);
}
})
.catch(() => {
gcalSelect.disabled = true;
gcalSelect.options[0].textContent = 'Failed to load calendars';
});
}
RsvAdminForm.bind(timetable_settings_form);
})();
</script>
<h2>Capacity</h2>
<p>Define capacities for timetable.</p>
<?php
rsv_create_capacity_form($id);
?>
<script>
(function() {
const DAY_DOW = { monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 0 };
function nearest_weekday(base_str, dow) {
const d = new Date(base_str + 'T00:00:00');
const diff = (dow - d.getDay() + 7) % 7;
d.setDate(d.getDate() + diff);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
RsvAdminForm.bind(document.getElementById('create_capacity_form'), {
// Expand one form submission into one capacity row per selected weekday.
transform: (body, form) => {
const fd = new FormData(form);
const is_repeating = fd.get('is_repeating') !== null;
const repeat_period_in_days = is_repeating
? parseInt(fd.get('repeat_period_in_days')) * parseInt(fd.get('repeat_period_multiplier'))
: 1;
const repeat_times = is_repeating ? parseInt(fd.get('repeat_times')) : 0;
const common = {
start_time: time_to_minutes(fd.get('start_time')),
end_time: time_to_minutes(fd.get('end_time')),
capacity: parseInt(fd.get('capacity')),
min_lead_time_minutes: parseInt(fd.get('min_lead_time_minutes')),
requires_confirmation: fd.get('requires_confirmation') !== null,
repeat_period_in_days,
repeat_times,
};
const base_date = fd.get('date');
const selected_days = Object.keys(DAY_DOW).filter(day => fd.get(day) !== null);
return selected_days.length > 0
? selected_days.map(day => ({ ...common, date: nearest_weekday(base_date, DAY_DOW[day]) }))
: [{ ...common, date: base_date }];
},
refresh: () => capacity_dt.refresh(),
});
})();
</script>
<div id="capacity_table"></div>
<script>
function minutes_to_time(minutes) {
const h = Math.floor(minutes / 60).toString().padStart(2, '0');
const m = (minutes % 60).toString().padStart(2, '0');
return `${h}:${m}`;
}
function time_to_minutes(time_str) {
const [h, m] = time_str.split(':').map(Number);
return h * 60 + m;
}
function rsv_render_capacity_inline_form(dt, row, data) {
const resource_with_time_conversion = {
...dt.resource,
put(id, form_data) {
return dt.resource.put(id, {
...form_data,
start_time: time_to_minutes(form_data.start_time),
end_time: time_to_minutes(form_data.end_time),
});
},
};
return RsvInlineFormBuilder.create(resource_with_time_conversion)
.input_hidden('min_lead_time_minutes', 0)
.fieldset('Datum a čas', '50%')
.input_date('date', 'Datum', data?.date ?? '')
.input_time('start_time', 'Začátek', minutes_to_time(data?.start_time ?? 0))
.input_time('end_time', 'Konec', minutes_to_time(data?.end_time ?? 0))
.fieldset('Kapacita a opakování', '50%')
.input_number('capacity', 'Kapacita', data?.capacity ?? '')
.input_number('repeat_period_in_days', 'Dnů mezi opakováním', data?.repeat_period_in_days ?? '')
.input_number('repeat_times', 'Počet opakování', data?.repeat_times ?? '')
.input_checkbox('requires_confirmation', 'Vyžaduje potvrzení', data?.requires_confirmation == 1)
.build({
id: data?.id,
colspan: 8,
save_label: 'Aktualizovat',
on_success: () => capacity_dt.refresh(),
on_cancel: () => capacity_dt.refresh(),
if (gcalSelect) {
fetch('<?= esc_js(get_rest_url(null, 'reservations/v1/google-calendars')) ?>', {
credentials: 'same-origin',
headers: { 'X-WP-Nonce': ReservairServiceAPI.nonce },
})
.then(r => r.json())
.then(calendars => {
for (const cal of calendars) {
const opt = document.createElement('option');
opt.value = cal.id;
opt.textContent = cal.summary;
if (cal.id === currentCalId) opt.selected = true;
gcalSelect.appendChild(opt);
}
})
.catch(() => {
gcalSelect.disabled = true;
gcalSelect.options[0].textContent = 'Failed to load calendars';
});
}
var capacity_dt = RsvDataGrid.create_data_grid(capacity_table,
RsvTimetableCapacityResource(<?= $id ?>),
{
'id': RsvDataGrid.column('ID', false),
'date': RsvDataGrid.action_column('Datum', false, {
'Edit': RsvDataGrid.edit_action((dt, row, data) => {
row.classList.add('inline-edit-row', 'inline-edit-row-post', 'quick-edit-row', 'quick-edit-row-post', 'inline-edit-post', 'inline-editor');
row.replaceChildren(rsv_render_capacity_inline_form(dt, row, data));
}),
'Trash': RsvDataGrid.func_action((dt, row, data) => {
dt.resource.delete(data.id).then(() => dt.refresh());
}),
}),
'start_time': RsvDataGrid.column('Začátek', false),
'end_time': RsvDataGrid.column('Konec', false),
'capacity': RsvDataGrid.column('Kapacita', false),
'repeat_period_in_days': RsvDataGrid.column('Dnů mezi opakováním', false),
'repeat_times': RsvDataGrid.column('Počet opakování', false),
'requires_confirmation': RsvDataGrid.column('Vyžaduje potvrzení', false),
}, );
capacity_dt.map_column('start_time', (dt, row, data) => {
const td = document.createElement('td');
td.innerText = minutes_to_time(data.start_time);
return td;
});
capacity_dt.map_column('end_time', (dt, row, data) => {
const td = document.createElement('td');
td.innerText = minutes_to_time(data.end_time);
return td;
});
capacity_dt.refresh();
</script>
<?php
}
function rsv_timetable_view_page(int $id): void {
$timetable = (new RsvTimetableService())->get($id);
if ($timetable === null) {
echo '<div class="notice notice-error"><p>Timetable not found.</p></div>';
return;
}
?>
<h1><?= esc_html($timetable->name) ?></h1>
<a href="<?= esc_url(menu_page_url('timetable-settings', false)) ?>">← Back to Timetables</a>
<hr>
<table class="form-table">
<tr><th>Name</th><td><?= esc_html($timetable->name) ?></td></tr>
<tr><th>Block size</th><td><?= esc_html($timetable->block_size) ?> minutes</td></tr>
<?php if ($timetable->maintainer_email): ?>
<tr><th>Maintainer email</th><td><?= esc_html($timetable->maintainer_email) ?></td></tr>
<?php endif; ?>
</table>
<h2>Reservations</h2>
<div id="timetable_reservations_table"></div>
<script>
RsvDataGrid.create_data_grid(
timetable_reservations_table,
RsvTimetableReservationResource(<?= $id ?>),
{
'id': RsvDataGrid.column('ID', false),
'reservation_id': RsvDataGrid.action_column('Reservation', false, {
'Accept': RsvDataGrid.func_action(
(dt, row, data) => RsvReservationClient.accept(data.reservation_id).then(() => dt.refresh()),
(item) => item.pending_confirmation_id !== null
),
'Refuse': RsvDataGrid.func_action(
(dt, row, data) => RsvReservationClient.refuse(data.reservation_id).then(() => dt.refresh()),
(item) => item.pending_confirmation_id !== null
),
}),
'start_utc': RsvDataGrid.column('Start', false),
'end_utc': RsvDataGrid.column('End', false),
'is_confirmed': RsvDataGrid.column('Status', false),
}
)
.map_column('is_confirmed', (dt, row, data) => {
const td = document.createElement('td');
td.textContent = data.pending_confirmation_id !== null ? 'Pending'
: data.is_confirmed == 1 ? 'Confirmed'
: 'Refused';
return td;
})
.refresh();
</script>
<?php
}
function rsv_timetable_edit_page($id) {
rsv_timetable_capacity_view($id);
}
RsvAdminForm.bind(timetable_settings_form);
})();
</script>
<h2>Capacity</h2>
function rsv_timetable_page() {
if (isset($_GET['action'])) {
if ($_GET['action'] === 'view' && isset($_GET['id'])) {
rsv_timetable_view_page(intval($_GET['id']));
return;
} else if($_GET['action'] === 'edit' && isset($_GET['id'])) {
rsv_timetable_edit_page(intval($_GET['id']));
return;
}
// Deletion is intentionally not handled here: a state-changing GET is
// CSRF-prone. Timetables are deleted via the nonce-authenticated REST
// DELETE /timetable/{id} (see the "Trash" action in the list grid).
<p>Define capacities for timetable.</p>
<?php $this->create_capacity_form($id); ?>
<script>
(function() {
const DAY_DOW = { monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 0 };
function nearest_weekday(base_str, dow) {
const d = new Date(base_str + 'T00:00:00');
const diff = (dow - d.getDay() + 7) % 7;
d.setDate(d.getDate() + diff);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
RsvAdminForm.bind(document.getElementById('create_capacity_form'), {
// Expand one form submission into one capacity row per selected weekday.
transform: (body, form) => {
const fd = new FormData(form);
const is_repeating = fd.get('is_repeating') !== null;
const repeat_period_in_days = is_repeating
? parseInt(fd.get('repeat_period_in_days')) * parseInt(fd.get('repeat_period_multiplier'))
: 1;
const repeat_times = is_repeating ? parseInt(fd.get('repeat_times')) : 0;
const common = {
start_time: time_to_minutes(fd.get('start_time')),
end_time: time_to_minutes(fd.get('end_time')),
capacity: parseInt(fd.get('capacity')),
min_lead_time_minutes: parseInt(fd.get('min_lead_time_minutes')),
requires_confirmation: fd.get('requires_confirmation') !== null,
repeat_period_in_days,
repeat_times,
};
const base_date = fd.get('date');
const selected_days = Object.keys(DAY_DOW).filter(day => fd.get(day) !== null);
return selected_days.length > 0
? selected_days.map(day => ({ ...common, date: nearest_weekday(base_date, DAY_DOW[day]) }))
: [{ ...common, date: base_date }];
},
refresh: () => capacity_dt.refresh(),
});
})();
</script>
<div id="capacity_table"></div>
<script>
function minutes_to_time(minutes) {
const h = Math.floor(minutes / 60).toString().padStart(2, '0');
const m = (minutes % 60).toString().padStart(2, '0');
return `${h}:${m}`;
}
function time_to_minutes(time_str) {
const [h, m] = time_str.split(':').map(Number);
return h * 60 + m;
}
function rsv_render_capacity_inline_form(dt, row, data) {
const resource_with_time_conversion = {
...dt.resource,
put(id, form_data) {
return dt.resource.put(id, {
...form_data,
start_time: time_to_minutes(form_data.start_time),
end_time: time_to_minutes(form_data.end_time),
});
},
};
return RsvInlineFormBuilder.create(resource_with_time_conversion)
.input_hidden('min_lead_time_minutes', 0)
.fieldset('Datum a čas', '50%')
.input_date('date', 'Datum', data?.date ?? '')
.input_time('start_time', 'Začátek', minutes_to_time(data?.start_time ?? 0))
.input_time('end_time', 'Konec', minutes_to_time(data?.end_time ?? 0))
.fieldset('Kapacita a opakování', '50%')
.input_number('capacity', 'Kapacita', data?.capacity ?? '')
.input_number('repeat_period_in_days', 'Dnů mezi opakováním', data?.repeat_period_in_days ?? '')
.input_number('repeat_times', 'Počet opakování', data?.repeat_times ?? '')
.input_checkbox('requires_confirmation', 'Vyžaduje potvrzení', data?.requires_confirmation == 1)
.build({
id: data?.id,
colspan: 8,
save_label: 'Aktualizovat',
on_success: () => capacity_dt.refresh(),
on_cancel: () => capacity_dt.refresh(),
});
}
var capacity_dt = RsvDataGrid.create_data_grid(capacity_table,
RsvTimetableCapacityResource(<?= $id ?>),
{
'id': RsvDataGrid.column('ID', false),
'date': RsvDataGrid.action_column('Datum', false, {
'Edit': RsvDataGrid.edit_action((dt, row, data) => {
row.classList.add('inline-edit-row', 'inline-edit-row-post', 'quick-edit-row', 'quick-edit-row-post', 'inline-edit-post', 'inline-editor');
row.replaceChildren(rsv_render_capacity_inline_form(dt, row, data));
}),
'Trash': RsvDataGrid.func_action((dt, row, data) => {
dt.resource.delete(data.id).then(() => dt.refresh());
}),
}),
'start_time': RsvDataGrid.column('Začátek', false),
'end_time': RsvDataGrid.column('Konec', false),
'capacity': RsvDataGrid.column('Kapacita', false),
'repeat_period_in_days': RsvDataGrid.column('Dnů mezi opakováním', false),
'repeat_times': RsvDataGrid.column('Počet opakování', false),
'requires_confirmation': RsvDataGrid.column('Vyžaduje potvrzení', false),
}, );
capacity_dt.map_column('start_time', (dt, row, data) => {
const td = document.createElement('td');
td.innerText = minutes_to_time(data.start_time);
return td;
});
capacity_dt.map_column('end_time', (dt, row, data) => {
const td = document.createElement('td');
td.innerText = minutes_to_time(data.end_time);
return td;
});
capacity_dt.refresh();
</script>
<?php
}
rsv_timetable_list_page();
private function create_capacity_form(int $timetable_id): void {
$form = RsvFormBuilder::create('create_capacity_form', get_rest_url(null, 'reservations/v1/timetable/' . $timetable_id . '/capacity'), 'POST', 'Capacity created.');
$form->date('date', 'First Date', 'Od kterého datumu platí tato kapacita.', true, new DateTime()->format('Y-m-d'));
$form->group('Availability Range', fn($g) => $g
->time('start_time', 'Start')
->time('end_time', 'End')
);
$form->number('capacity', 'Capacity', 'How many reservations can overlap on the same time.', true, 1, 1);
$form->number('min_lead_time_minutes', 'Minimum lead time (minutes)', 'How many minutes in advance must be the reservation created. This is useful if it takes some time to prepare the reservation.', true);
$form->checkbox('requires_confirmation', 'Requires Confirmation?', 'If checked, all the reservations that overlap this capacity will require confirmation from maintainer. The maintainer will receive an email asking for the confirmation.', true);
$form->custom('Is Repeating Event', function() {
return '
<input id="is_repeating" class="regular-text" type="checkbox" name="is_repeating" checked="true">
<p>If the capacity is available repeatingly. For example: repeat each monday every week.</p>
';
});
$form->number('repeat_period_in_days', 'Repeat Period (days)', 'How many days between each repetition.', true);
$form->custom('Apply to Days', function() {
return '
<table class="option-table">
<tbody>
<tr class="form-day-names">
<td>Monday</td>
<td>Tuesday</td>
<td>Wednesday</td>
<td>Thursday</td>
<td>Friday</td>
<td>Saturday</td>
<td>Sunday</td>
</tr>
<tr class="form-days">
<td><input class="is-repeating-input" type="checkbox" name="monday"></td>
<td><input class="is-repeating-input" type="checkbox" name="tuesday"></td>
<td><input class="is-repeating-input" type="checkbox" name="wednesday"></td>
<td><input class="is-repeating-input" type="checkbox" name="thursday"></td>
<td><input class="is-repeating-input" type="checkbox" name="friday"></td>
<td><input class="is-repeating-input" type="checkbox" name="saturday"></td>
<td><input class="is-repeating-input" type="checkbox" name="sunday"></td>
</tr>
</tbody>
</table>';
});
$form->submit('Create Capacity', 'button-primary', 'submit');
$form->output();
}
}