Reimplemented admin room pages

This commit is contained in:
Igor Scheller 2023-02-26 11:27:41 +01:00 committed by Michael Weimann
parent baca49c53b
commit a464682b47
33 changed files with 800 additions and 402 deletions

View File

@ -157,6 +157,17 @@ $route->addGroup(
}
);
// Rooms
$route->addGroup(
'/rooms',
function (RouteCollector $route): void {
$route->get('', 'Admin\\RoomsController@index');
$route->post('', 'Admin\\RoomsController@delete');
$route->get('/edit[/{room_id:\d+}]', 'Admin\\RoomsController@edit');
$route->post('/edit[/{room_id:\d+}]', 'Admin\\RoomsController@save');
}
);
// User
$route->addGroup(
'/user/{user_id:\d+}',

View File

@ -2,6 +2,7 @@
use Engelsystem\Helpers\Carbon;
use Engelsystem\Models\AngelType;
use Engelsystem\Models\Room;
use Engelsystem\Models\UserAngelType;
use Engelsystem\ShiftsFilter;
use Engelsystem\ShiftsFilterRenderer;
@ -239,9 +240,13 @@ function angeltype_controller_shiftsFilterDays(AngelType $angeltype)
function angeltype_controller_shiftsFilter(AngelType $angeltype, $days)
{
$request = request();
$roomIds = Room::query()
->select('id')
->pluck('id')
->toArray();
$shiftsFilter = new ShiftsFilter(
auth()->can('user_shifts_admin'),
Room_ids(),
$roomIds,
[$angeltype->id]
);
$selected_day = date('Y-m-d');

View File

@ -2,6 +2,7 @@
use Engelsystem\Models\AngelType;
use Engelsystem\Models\News;
use Engelsystem\Models\Room;
use Engelsystem\Models\Shifts\Shift;
use Engelsystem\ShiftsFilter;
@ -24,7 +25,7 @@ function public_dashboard_controller()
}
$angelTypes = collect(unrestricted_angeltypes());
$rooms = $requestRooms ?: Rooms()->pluck('id')->toArray();
$rooms = $requestRooms ?: Room::orderBy('name')->get()->pluck('id')->toArray();
$angelTypes = $requestAngelTypes ?: $angelTypes->pluck('id')->toArray();
$filterValues = [
'userShiftsAdmin' => false,

View File

@ -73,8 +73,8 @@ function rooms_controller(): array
return match ($action) {
'view' => room_controller(),
'list' => throw_redirect(page_link_to('admin_rooms')),
default => throw_redirect(page_link_to('admin_rooms')),
'list' => throw_redirect(page_link_to('admin/rooms')),
default => throw_redirect(page_link_to('admin/rooms')),
};
}

View File

@ -67,7 +67,7 @@ function shift_edit_controller()
}
$rooms = [];
foreach (Rooms() as $room) {
foreach (Room::orderBy('name')->get() as $room) {
$rooms[$room->id] = $room->name;
}
$angeltypes = AngelType::all()->pluck('name', 'id')->toArray();

View File

@ -12,7 +12,6 @@ $includeFiles = [
__DIR__ . '/../includes/sys_template.php',
__DIR__ . '/../includes/model/NeededAngelTypes_model.php',
__DIR__ . '/../includes/model/Room_model.php',
__DIR__ . '/../includes/model/ShiftEntry_model.php',
__DIR__ . '/../includes/model/Shifts_model.php',
__DIR__ . '/../includes/model/ShiftsFilter.php',
@ -61,7 +60,6 @@ $includeFiles = [
__DIR__ . '/../includes/pages/admin_arrive.php',
__DIR__ . '/../includes/pages/admin_free.php',
__DIR__ . '/../includes/pages/admin_groups.php',
__DIR__ . '/../includes/pages/admin_rooms.php',
__DIR__ . '/../includes/pages/admin_shifts.php',
__DIR__ . '/../includes/pages/admin_user.php',
__DIR__ . '/../includes/pages/guest_login.php',

View File

@ -1,122 +0,0 @@
<?php
use Engelsystem\Models\Room;
use Engelsystem\ValidationResult;
use Illuminate\Support\Collection;
/**
* Validate a name for a room.
*
* @param string $name The new name
* @param int $room_id The room id
* @return ValidationResult
*/
function Room_validate_name(string $name, int $room_id)
{
$valid = true;
if (empty($name)) {
$valid = false;
}
$roomCount = Room::query()
->where('name', $name)
->where('id', '!=', $room_id)
->count();
if ($roomCount) {
$valid = false;
}
return new ValidationResult($valid, $name);
}
/**
* returns a list of rooms.
*
* @return Room[]|Collection
*/
function Rooms()
{
return Room::orderBy('name')->get();
}
/**
* Returns Room id array
*
* @return int[]
*/
function Room_ids()
{
return Room::query()
->select('id')
->pluck('id')
->toArray();
}
/**
* Delete a room
*
* @param Room $room
*/
function Room_delete(Room $room)
{
$room->delete();
engelsystem_log('Room deleted: ' . $room->name);
}
/**
* Create a new room
*
* @param string $name Name of the room
* @param string|null $map_url URL to a map tha can be displayed in an iframe
* @param string|null $description
*
* @return null|int
*/
function Room_create(string $name, string $map_url = null, string $description = null, string $dect = null)
{
$room = new Room();
$room->name = $name;
$room->description = $description;
$room->map_url = $map_url;
$room->dect = $dect;
$room->save();
engelsystem_log(
'Room created: ' . $name
. ', dect: ' . $dect
. ', map_url: ' . $map_url
. ', description: ' . $description
);
return $room->id;
}
/**
* Update a room
*
* @param int $room_id The rooms id
* @param string $name Name of the room
* @param string|null $map_url URL to a map tha can be displayed in an iframe
* @param string|null $description Markdown description
*/
function Room_update(
int $room_id,
string $name,
string $map_url = null,
string $description = null,
string $dect = null
) {
$room = Room::find($room_id);
$room->name = $name;
$room->description = $description ?: null;
$room->map_url = $map_url ?: null;
$room->dect = $dect ?: null;
$room->save();
engelsystem_log(
'Room updated: ' . $name .
', dect: ' . $dect .
', map_url: ' . $map_url .
', description: ' . $description
);
}

View File

@ -1,238 +0,0 @@
<?php
use Engelsystem\Models\AngelType;
use Engelsystem\Models\Room;
use Engelsystem\Models\Shifts\NeededAngelType;
use Engelsystem\Models\User\User;
/**
* @return string
*/
function admin_rooms_title()
{
return __('Rooms');
}
/**
* @return string
*/
function admin_rooms()
{
$rooms_source = Rooms();
$rooms = [];
$request = request();
foreach ($rooms_source as $room) {
$rooms[] = [
'name' => Room_name_render($room),
'dect' => icon_bool($room->dect),
'map_url' => icon_bool($room->map_url),
'actions' => table_buttons([
button(
page_link_to('admin_rooms', ['show' => 'edit', 'id' => $room->id]),
icon('pencil') . __('edit'),
'btn-sm'
),
button(
page_link_to('admin_rooms', ['show' => 'delete', 'id' => $room->id]),
icon('trash') . __('delete'),
'btn-sm'
),
]),
];
}
$room = null;
if ($request->has('show')) {
$msg = '';
$name = '';
$map_url = null;
$description = null;
$dect = null;
$room_id = 0;
$angeltypes_source = AngelType::all();
$angeltypes = [];
$angeltypes_count = [];
foreach ($angeltypes_source as $angeltype) {
$angeltypes[$angeltype->id] = $angeltype->name;
$angeltypes_count[$angeltype->id] = 0;
}
if (test_request_int('id')) {
$room = Room::find($request->input('id'));
if (!$room) {
throw_redirect(page_link_to('admin_rooms'));
}
$room_id = $room->id;
$name = $room->name;
$map_url = $room->map_url;
$description = $room->description;
$dect = $room->dect;
$angeltypes_count = NeededAngelType::whereRoomId($room_id)
->pluck('count', 'angel_type_id')
->toArray() + $angeltypes_count;
}
if ($request->input('show') == 'edit') {
if ($request->hasPostData('submit')) {
$valid = true;
if ($request->has('name') && strlen(strip_request_tags('name')) > 0) {
$result = Room_validate_name(strip_request_tags('name'), $room_id);
if (!$result->isValid()) {
$valid = false;
$msg .= error(__('This name is already in use.'), true);
} else {
$name = $result->getValue();
}
} else {
$valid = false;
$msg .= error(__('Please enter a name.'), true);
}
if ($request->has('map_url')) {
$map_url = strip_request_item('map_url');
}
if ($request->has('description')) {
$description = strip_request_item_nl('description');
}
if ($request->has('dect')) {
$dect = strip_request_item_nl('dect');
}
foreach ($angeltypes as $angeltype_id => $angeltype) {
$angeltypes_count[$angeltype_id] = 0;
$queryKey = 'angeltype_count_' . $angeltype_id;
if (!$request->has($queryKey)) {
continue;
}
if (preg_match('/^\d{1,4}$/', $request->input($queryKey))) {
$angeltypes_count[$angeltype_id] = $request->input($queryKey);
} else {
$valid = false;
$msg .= error(sprintf(
__('Please enter needed angels for type %s.'),
$angeltype
), true);
}
}
if ($valid) {
if (empty($room_id)) {
$room_id = Room_create($name, $map_url, $description, $dect);
} else {
Room_update($room_id, $name, $map_url, $description, $dect);
}
NeededAngelType::whereRoomId($room_id)->delete();
$needed_angeltype_info = [];
foreach ($angeltypes_count as $angeltype_id => $angeltype_count) {
$angeltype = AngelType::find($angeltype_id);
if (!empty($angeltype) && $angeltype_count > 0) {
$neededAngelType = new NeededAngelType();
$neededAngelType->room_id = $room_id;
$neededAngelType->angelType()->associate($angeltype);
$neededAngelType->count = $angeltype_count;
$neededAngelType->save();
$needed_angeltype_info[] = $angeltype->name . ': ' . $angeltype_count;
}
}
engelsystem_log(
'Set needed angeltypes of room ' . $name
. ' to: ' . join(', ', $needed_angeltype_info)
);
success(__('Room saved.'));
throw_redirect(page_link_to('admin_rooms'));
}
}
$angeltypes_count_form = [];
foreach ($angeltypes as $angeltype_id => $angeltypeName) {
$angeltypes_count_form[] = div('col-lg-4 col-md-6 col-xs-6', [
form_spinner('angeltype_count_' . $angeltype_id, $angeltypeName, $angeltypes_count[$angeltype_id]),
]);
}
return page_with_title(admin_rooms_title(), [
buttons([
button(page_link_to('admin_rooms'), __('back'), 'back'),
]),
$msg,
form([
div('row', [
div('col-md-6', [
form_text('name', __('Name'), $name, false, 35),
form_text('dect', __('DECT'), $dect),
form_text('map_url', __('Map URL'), $map_url),
form_info('', __('The map url is used to display an iframe on the room page.')),
form_textarea('description', __('Description'), $description),
form_info('', __('Please use markdown for the description.')),
]),
div('col-md-6', [
div('row', [
div('col-md-12', [
form_info(__('Needed angels:')),
]),
join($angeltypes_count_form),
]),
]),
]),
form_submit('submit', __('Save')),
]),
], true);
} elseif ($request->input('show') == 'delete') {
if ($request->hasPostData('ack')) {
$room = Room::find($room_id);
$shifts = $room->shifts;
foreach ($shifts as $shift) {
$shift = Shift($shift);
foreach ($shift->shiftEntries as $entry) {
event('shift.entry.deleting', [
'user' => User::find($entry['user_id']),
'start' => $shift->start,
'end' => $shift->end,
'name' => $shift->shiftType->name,
'title' => $shift->title,
'type' => $entry->angelType->name,
'room' => $room,
'freeloaded' => (bool) $entry['freeloaded'],
]);
}
}
Room_delete($room);
success(sprintf(__('Room %s deleted.'), $name));
throw_redirect(page_link_to('admin_rooms'));
}
return page_with_title(admin_rooms_title(), [
buttons([
button(page_link_to('admin_rooms'), __('back'), 'back'),
]),
sprintf(__('Do you want to delete room %s?'), $name),
form([
form_submit('ack', __('Delete'), 'delete btn-danger'),
], page_link_to('admin_rooms', ['show' => 'delete', 'id' => $room_id])),
], true);
}
}
return page_with_title(admin_rooms_title(), [
buttons([
button(page_link_to('admin_rooms', ['show' => 'edit']), __('add')),
]),
msg(),
table([
'name' => __('Name'),
'dect' => __('DECT'),
'map_url' => __('Map'),
'actions' => '',
], $rooms),
], true);
}

View File

@ -43,11 +43,8 @@ function admin_shifts()
$shift_over_midnight = true;
// Locations laden
$rooms = Rooms();
$room_array = [];
foreach ($rooms as $room) {
$room_array[$room->id] = $room->name;
}
$rooms = Room::orderBy('name')->get();
$room_array = $rooms->pluck('name', 'id')->toArray();
// Load angeltypes
/** @var AngelType[] $types */

View File

@ -115,7 +115,7 @@ function update_ShiftsFilter(ShiftsFilter $shiftsFilter, $user_shifts_admin, $da
*/
function load_rooms()
{
$rooms = Rooms();
$rooms = Room::orderBy('name')->get();
if ($rooms->isEmpty()) {
error(__('The administration has not configured any rooms yet.'));
throw_redirect(page_link_to('/'));

View File

@ -1,6 +1,7 @@
<?php
use Engelsystem\Models\Question;
use Engelsystem\Models\Room;
use Engelsystem\UserHintsRenderer;
/**
@ -90,7 +91,7 @@ function make_navigation()
'admin/questions' => ['Answer questions', 'question.edit'],
'shifttypes' => 'Shifttypes',
'admin_shifts' => 'Create shifts',
'admin_rooms' => 'Rooms',
'admin/rooms' => ['room.rooms', 'admin_rooms'],
'admin_groups' => 'Grouprights',
'admin/schedule' => ['schedule.import', 'schedule.import'],
'admin/logs' => ['log.log', 'admin_log'],
@ -152,10 +153,10 @@ function make_room_navigation($menu)
}
// Get a list of all rooms
$rooms = Rooms();
$rooms = Room::orderBy('name')->get();
$room_menu = [];
if (auth()->can('admin_rooms')) {
$room_menu[] = toolbar_dropdown_item(page_link_to('admin_rooms'), __('Manage rooms'), false, 'list');
$room_menu[] = toolbar_dropdown_item(page_link_to('admin/rooms'), __('Manage rooms'), false, 'list');
}
if (count($room_menu) > 0) {
$room_menu[] = toolbar_dropdown_item_divider();

View File

@ -61,15 +61,10 @@ function Room_view(Room $room, ShiftsFilterRenderer $shiftsFilterRenderer, Shift
$assignNotice,
auth()->can('admin_rooms') ? buttons([
button(
page_link_to('admin_rooms', ['show' => 'edit', 'id' => $room->id]),
page_link_to('admin/rooms/edit/' . $room->id),
icon('pencil') . __('edit'),
'btn'
),
button(
page_link_to('admin_rooms', ['show' => 'delete', 'id' => $room->id]),
icon('trash') . __('delete'),
'btn'
),
]) : '',
$dect,
$description,

View File

@ -254,6 +254,13 @@ ready(() => {
document.querySelectorAll('[data-bs-toggle="popover"]').forEach((element) => new bootstrap.Popover(element));
});
/**
* Init Bootstrap Tooltips
*/
ready(() => {
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((element) => new bootstrap.Tooltip(element));
});
/**
* Show oauth buttons on welcome title click
*/

View File

@ -46,6 +46,7 @@ $form-label-font-weight: $font-weight-bold;
@import '~bootstrap/scss/list-group';
@import '~bootstrap/scss/close';
@import '~bootstrap/scss/popover';
@import '~bootstrap/scss/tooltip';
@import '~bootstrap/scss/helpers';

View File

@ -209,3 +209,12 @@ msgstr "Arbeitseinsatz erfolgreich bearbeitet."
msgid "worklog.delete.success"
msgstr "Arbeitseinsatz erfolgreich gelöscht."
msgid "room.edit.success"
msgstr "Raum erfolgreich bearbeitet."
msgid "room.delete.success"
msgstr "Raum erfolgreich gelöscht."
msgid "validation.name.exists"
msgstr "Der Name wird bereits verwendet."

View File

@ -2812,6 +2812,12 @@ msgstr "Aktualisiert"
msgid "form.cancel"
msgstr "Abbrechen"
msgid "form.markdown"
msgstr "Du kannst hier Markdown verwenden"
msgid "form.required"
msgstr "Pflichtfeld"
msgid "schedule.import"
msgstr "Programm importieren"
@ -3189,3 +3195,30 @@ msgstr "E-Mail"
msgid "registration.register"
msgstr "Registrieren"
msgid "room.rooms"
msgstr "Räume"
msgid "room.name"
msgstr "Name"
msgid "room.dect"
msgstr "DECT"
msgid "room.map_url"
msgstr "Karte"
msgid "room.description"
msgstr "Beschreibung"
msgid "room.required_angels"
msgstr "Benötigte Engel"
msgid "room.map_url.info"
msgstr "Die Karte wird auf der Raum-Seite als iframe eingebettet."
msgid "room.create.title"
msgstr "Raum erstellen"
msgid "room.edit.title"
msgstr "Raum bearbeiten"

View File

@ -208,3 +208,12 @@ msgstr "Work log successfully updated."
msgid "worklog.delete.success"
msgstr "Work log successfully deleted."
msgid "room.edit.success"
msgstr "Room edited successfully."
msgid "room.delete.success"
msgstr "Room successfully deleted."
msgid "validation.name.exists"
msgstr "The name is already used."

View File

@ -79,6 +79,12 @@ msgstr "Updated"
msgid "form.cancel"
msgstr "Cancel"
msgid "form.required"
msgstr "Required"
msgid "form.markdown"
msgstr "You can use Markdown here"
msgid "schedule.import"
msgstr "Import schedule"
@ -452,3 +458,30 @@ msgstr "E-Mail"
msgid "registration.register"
msgstr "Register"
msgid "room.rooms"
msgstr "Rooms"
msgid "room.name"
msgstr "Name"
msgid "room.dect"
msgstr "DECT"
msgid "room.map_url"
msgstr "Map"
msgid "room.description"
msgstr "Description"
msgid "room.required_angels"
msgstr "Required angels"
msgid "room.map_url.info"
msgstr "The map will be embedded on the room page as an iframe."
msgid "room.create.title"
msgstr "Create room"
msgid "room.edit.title"
msgstr "Edit room"

View File

@ -0,0 +1,66 @@
{% extends 'admin/rooms/index.twig' %}
{% import 'macros/base.twig' as m %}
{% import 'macros/form.twig' as f %}
{% block title %}{{ room ? __('room.edit.title') : __('room.create.title') }}{% endblock %}
{% block row_content %}
<form method="post">
{{ csrf() }}
{{ f.hidden('id', room ? room.id : '') }}
<div class="row">
<div class="col-lg-6">
{{ f.input(
'name',
__('room.name'),
null,
{'required': true, 'entry_required_icon': true, 'value': f.formData('room', room ? room.name : '')}
) }}
{{ f.input('dect', __('room.dect'), null, {'value': f.formData('dect', room ? room.dect : '')}) }}
{{ f.input(
'map_url',
__('room.map_url'),
'url',
{'value': f.formData('map_url', room ? room.map_url : ''), 'info': __('room.map_url.info')}
) }}
{{ f.textarea(
'description',
__('room.description'),
{'value': f.formData('description', room ? room.description : ''), 'rows': 5, 'info': __('form.markdown')}
) }}
</div>
<div class="col-lg-6">
<h4>{{ __('room.required_angels') }}</h4>
{% for types in angel_types.chunk(3) %}
<div class="row">
{% for angel_type in types %}
{% set needed = needed_angel_types ? needed_angel_types.where('angel_type_id', angel_type.id).first() : null %}
{% set name = 'angel_type_' ~ angel_type.id %}
<div class="col-md-4">
{{ f.number(
name,
angel_type.name,
{'value': f.formData(name, needed ? needed.count : 0), 'min': 0, 'step': 1}
) }}
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
<div class="col-md-12">
<div class="btn-group">
{{ f.submit(__('form.save'), {'icon_left': 'save'}) }}
{% if room %}
{{ f.submit(__('form.delete'), {'name': 'delete', 'btn_type': 'danger', 'icon_left': 'trash'}) }}
{% endif %}
</div>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,71 @@
{% extends 'layouts/app.twig' %}
{% import 'macros/base.twig' as m %}
{% import 'macros/form.twig' as f %}
{% block title %}{{ __('room.rooms') }}{% endblock %}
{% block content %}
<div class="container">
<h1>
{{ block('title') }}
{% if is_index|default(false) %}
{{ m.button(m.icon('plus-lg'), url('/admin/rooms/edit'), 'secondary') }}
{% endif %}
</h1>
{% include 'layouts/parts/messages.twig' %}
<div class="row">
{% block row_content %}
<div class="col-md-12">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>{{ __('room.name') }}</th>
<th>{{ __('room.dect') }}</th>
<th>{{ __('room.map_url') }}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for room in rooms %}
<tr>
<td>
{{ m.icon('pin-map-fill') }}
<a href="{{ url('/rooms', {'action': 'view', 'room_id': room.id}) }}">
{{ room.name }}
</a>
</td>
<td>{{ m.iconBool(room.dect) }}</td>
<td>{{ m.iconBool(room.map_url) }}</td>
<td>
<div class="d-flex ms-auto">
{{ m.button(m.icon('pencil'), url('admin/rooms/edit/' ~ room.id), null, 'sm', __('form.edit')) }}
<form method="post" class="ps-1">
{{ csrf() }}
{{ f.hidden('id', room.id) }}
{{ f.button(m.icon('trash'), {'title': __('form.delete'), 'name': 'delete', 'type': 'submit', 'btn_type': 'danger', 'size': 'sm'}) }}
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
</div>
</div>
{% endblock %}

View File

@ -6,6 +6,12 @@
<span class="bi bi-{{ icon }} {% if color %} text-{{ color }} {% endif %}"></span>
{% endmacro %}
{% macro iconBool(value) %}
<span class="text-{% if value %}success{% else %}danger{% endif %}">
{{ _self.icon(value ? 'check-lg' : 'x-lg') }}
</span>
{% endmacro %}
{% macro alert(message, type, raw) %}
<div class="alert alert-{{ type|default('info') }}" role="alert">
{%- if raw|default(false) -%}

View File

@ -1,5 +1,9 @@
{% macro entry_required() %}
<span class="bi bi-exclamation-triangle text-info"></span>
<span class="text-info" title="{{ __('form.required') }}">*</span>
{%- endmacro %}
{% macro info(text) %}
<span class="bi bi-info-circle-fill text-info" data-bs-toggle="tooltip" title="{{ text | e('html_attr') }}"></span>
{%- endmacro %}
{% macro input(name, label, type, opt) %}
@ -10,6 +14,9 @@
{% if opt.entry_required_icon|default(false) %}
{{ _self.entry_required() }}
{% endif %}
{% if opt.info is defined %}
{{ _self.info(opt.info) }}
{% endif %}
</label>
{%- endif %}
<input
@ -35,10 +42,58 @@
</div>
{%- endmacro %}
{% macro number(name, label, opt) %}
<div class="mb-3">
{% if label -%}
<label class="form-label" for="{{ name }}">{{ label }}</label>
{% if opt.entry_required_icon|default(false) %}
{{ _self.entry_required() }}
{% endif %}
{% if opt.info is defined %}
{{ _self.info(opt.info) }}
{% endif %}
{%- endif %}
<div class="input-group">
<input
type="number" class="form-control"
id="{{ name }}" name="{{ name }}"
value="{{ opt.value|default('')|escape('html_attr') }}"
{%- if opt.min is defined %} min="{{ opt.min }}"{% endif %}
{%- if opt.max is defined %} max="{{ opt.max }}"{% endif %}
{%- if opt.step is defined %} step="{{ opt.step }}"{% endif %}
{%- if opt.required|default(false) %}
required
{%- endif -%}
{%- if opt.disabled|default(false) %}
disabled
{%- endif -%}
{%- if opt.readonly|default(false) %}
readonly
{%- endif -%}
>
<button class="btn btn-secondary spinner-down" type="button" data-input-id="{{ name }}">
<span class="bi bi-dash-lg"></span>
</button>
<button class="btn btn-secondary spinner-up" type="button" data-input-id="{{ name }}">
<span class="bi bi-plus-lg"></span>
</button>
</div>
</div>
{% endmacro %}
{% macro textarea(name, label, opt) %}
<div class="mb-3">
{% if label -%}
<label class="form-label" for="{{ name }}">{{ label }}</label>
{% if opt.entry_required_icon|default(false) %}
{{ _self.entry_required() }}
{% endif %}
{% if opt.info is defined %}
{{ _self.info(opt.info) }}
{% endif %}
{%- endif %}
<textarea class="form-control" id="{{ name }}" name="{{ name }}"
{%- if opt.required|default(false) %}
@ -59,6 +114,9 @@
{% if opt.entry_required_icon|default(false) %}
{{ _self.entry_required() }}
{% endif %}
{% if opt.info is defined %}
{{ _self.info(opt.info) }}
{% endif %}
</label>
{% endif %}
<select id="{{ name }}" name="{{ name }}"
@ -74,18 +132,22 @@
</div>
{%- endmacro %}
{% macro checkbox(name, label, checked, value, disabled, raw_label) %}
{% macro checkbox(name, label, checked, value, disabled, raw_label, opt) %}
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="{{ name }}" name="{{ name }}" value="{{ value|default('1') }}"
<input class="form-check-input" type="checkbox" id="{{ name }}" name="{{ name }}"
value="{{ value|default('1') }}"
{%- if checked|default(false) %} checked{% endif %}
{%- if disabled|default(false) %} disabled{% endif %}
/>
>
<label class="form-check-label" for="{{ name }}">
{%- if raw_label|default(false) -%}
{{ label|raw }}
{%- else -%}
{{ label }}
{%- endif -%}
{% if opt.info is defined %}
{{ _self.info(opt.info) }}
{% endif %}
</label>
</div>
{%- endmacro %}
@ -103,7 +165,9 @@
{%- if opt.title is defined %} title="{{ opt.title }}"{% endif %}
{%- if opt.value is defined or opt.name is defined %} value="{{ opt.value|default('1') }}"{% endif -%}
>
{%- if opt.icon_left is defined %}<span class="bi bi-{{ opt.icon_left }}"></span>{% endif %}
{{ label }}
{%- if opt.icon_right is defined %}<span class="bi bi-{{ opt.icon_right }}"></span>{% endif %}
</button>
{%- endmacro %}
@ -113,10 +177,15 @@
{% macro switch(name, label, checked, opt) %}
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="{{ name }}" name="{{ name }}" value="{{ opt.value|default('1') }}"
<input class="form-check-input" type="checkbox" id="{{ name }}" name="{{ name }}"
value="{{ opt.value|default('1') }}"
{%- if checked|default(false) %} checked{% endif %}
{%- if opt.disabled|default(false) %} disabled{% endif %}
>
<label class="form-check-label" for="{{ name }}">{{ label }}</label>
</div>
{%- endmacro %}
{% macro formData(name, default) -%}
{{ session_pop('form-data-' ~ name, default) }}
{%- endmacro %}

View File

@ -359,6 +359,10 @@
<h4><code>icon(icon_name)</code></h4>
<p>{{ m.icon('star') }}</p>
</div>
<div class="col-md-3">
<h4><code>iconBool(true)</code></h4>
<p>{{ m.iconBool(true) }} {{ m.iconBool(false) }}</p>
</div>
<div class="col-md-3">
<h4><code>alert(message, type)</code></h4>
<p>{{ m.alert('Test content', 'info') }}</p>

View File

@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Controllers\Admin;
use Engelsystem\Controllers\BaseController;
use Engelsystem\Controllers\HasUserNotifications;
use Engelsystem\Http\Exceptions\ValidationException;
use Engelsystem\Http\Redirector;
use Engelsystem\Http\Request;
use Engelsystem\Http\Response;
use Engelsystem\Http\Validation\Validator;
use Engelsystem\Models\AngelType;
use Engelsystem\Models\Room;
use Engelsystem\Models\Shifts\NeededAngelType;
use Illuminate\Database\Eloquent\Collection;
use Psr\Log\LoggerInterface;
class RoomsController extends BaseController
{
use HasUserNotifications;
/** @var array<string> */
protected array $permissions = [
'admin_rooms',
];
public function __construct(
protected LoggerInterface $log,
protected Room $room,
protected Redirector $redirect,
protected Response $response
) {
}
public function index(): Response
{
$rooms = $this->room
->orderBy('name')
->get();
return $this->response->withView(
'admin/rooms/index',
['rooms' => $rooms, 'is_index' => true]
);
}
public function edit(Request $request): Response
{
$roomId = (int) $request->getAttribute('room_id');
$room = $this->room->find($roomId);
return $this->showEdit($room);
}
public function save(Request $request): Response
{
$roomId = (int) $request->getAttribute('room_id');
/** @var Room $room */
$room = $this->room->findOrNew($roomId);
/** @var Collection|AngelType[] $angelTypes */
$angelTypes = AngelType::all();
$validation = [];
foreach ($angelTypes as $angelType) {
$validation['angel_type_' . $angelType->id] = 'optional|int';
}
if ($request->request->has('delete')) {
return $this->delete($request);
}
$data = $this->validate(
$request,
[
'name' => 'required',
'description' => 'required|optional',
'dect' => 'required|optional',
'map_url' => 'optional|url',
] + $validation
);
if (Room::whereName($data['name'])->where('id', '!=', $room->id)->exists()) {
throw new ValidationException((new Validator())->addErrors(['name' => ['validation.name.exists']]));
}
$room->name = $data['name'];
$room->description = $data['description'];
$room->dect = $data['dect'];
$room->map_url = $data['map_url'];
$room->save();
$room->neededAngelTypes()->getQuery()->delete();
$angelsInfo = '';
foreach ($angelTypes as $angelType) {
$count = $data['angel_type_' . $angelType->id];
if (!$count) {
continue;
}
$neededAngelType = new NeededAngelType();
$neededAngelType->room()->associate($room);
$neededAngelType->angelType()->associate($angelType);
$neededAngelType->count = $data['angel_type_' . $angelType->id];
$neededAngelType->save();
$angelsInfo .= sprintf(', %s: %s', $angelType->name, $count);
}
$this->log->info(
'Updated room "{name}": {description} {dect} {map_url} {angels}',
[
'name' => $room->name,
'description' => $room->description,
'dect' => $room->dect,
'map_url' => $room->map_url,
'angels' => $angelsInfo,
]
);
$this->addNotification('room.edit.success');
return $this->redirect->to('/admin/rooms');
}
public function delete(Request $request): Response
{
$data = $this->validate($request, [
'id' => 'required|int',
'delete' => 'checked',
]);
$room = $this->room->findOrFail($data['id']);
$shifts = $room->shifts;
foreach ($shifts as $shift) {
foreach ($shift->shiftEntries as $entry) {
event('shift.entry.deleting', [
'user' => $entry->user,
'start' => $shift->start,
'end' => $shift->end,
'name' => $shift->shiftType->name,
'title' => $shift->title,
'type' => $entry->angelType->name,
'room' => $room,
'freeloaded' => $entry->freeloaded,
]);
}
}
$room->delete();
$this->log->info('Deleted room {room}', ['room' => $room->name]);
$this->addNotification('room.delete.success');
return $this->redirect->to('/admin/rooms');
}
protected function showEdit(?Room $room): Response
{
$angeltypes = AngelType::all()
->sortBy('name');
return $this->response->withView(
'admin/rooms/edit',
['room' => $room, 'angel_types' => $angeltypes, 'needed_angel_types' => $room?->neededAngelTypes]
);
}
}

View File

@ -167,7 +167,9 @@ class Response extends SymfonyResponse implements ResponseInterface
throw new InvalidArgumentException('Session not defined');
}
$this->session->set('form-data', $input);
foreach ($input as $name => $value) {
$this->session->set('form-data-' . $name, $value);
}
return $this;
}

View File

@ -103,4 +103,11 @@ class Validator
{
return $this->errors;
}
public function addErrors(array $errors): self
{
$this->errors = array_merge($this->errors, $errors);
return $this;
}
}

View File

@ -128,10 +128,6 @@ class LegacyMiddleware implements MiddlewareInterface
$title = admin_free_title();
$content = admin_free();
return [$title, $content];
case 'admin_rooms':
$title = admin_rooms_title();
$content = admin_rooms();
return [$title, $content];
case 'admin_groups':
$title = admin_groups_title();
$content = admin_groups();

View File

@ -22,6 +22,18 @@ class Session extends TwigExtension
return [
new TwigFunction('session_get', [$this->session, 'get']),
new TwigFunction('session_set', [$this->session, 'set']),
new TwigFunction('session_pop', [$this, 'sessionPop']),
];
}
/**
* Returns the requested attribute and removes it from the session
*/
public function sessionPop(string $name, mixed $default = null): mixed
{
$value = $this->session->get($name, $default);
$this->session->remove($name);
return $value;
}
}

View File

@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
namespace Engelsystem\Test\Unit\Controllers\Admin;
use Engelsystem\Controllers\Admin\RoomsController;
use Engelsystem\Events\EventDispatcher;
use Engelsystem\Helpers\Carbon;
use Engelsystem\Http\Exceptions\ValidationException;
use Engelsystem\Http\Redirector;
use Engelsystem\Http\Validation\Validator;
use Engelsystem\Models\AngelType;
use Engelsystem\Models\Room;
use Engelsystem\Models\Shifts\NeededAngelType;
use Engelsystem\Models\Shifts\Shift;
use Engelsystem\Models\Shifts\ShiftEntry;
use Engelsystem\Models\User\User;
use Engelsystem\Test\Unit\Controllers\ControllerTest;
use PHPUnit\Framework\MockObject\MockObject;
class RoomsControllerTest extends ControllerTest
{
protected Redirector|MockObject $redirect;
/**
* @covers \Engelsystem\Controllers\Admin\RoomsController::__construct
* @covers \Engelsystem\Controllers\Admin\RoomsController::index
*/
public function testIndex(): void
{
/** @var RoomsController $controller */
$controller = $this->app->make(RoomsController::class);
Room::factory(5)->create();
$this->response->expects($this->once())
->method('withView')
->willReturnCallback(function (string $view, array $data) {
$this->assertEquals('admin/rooms/index', $view);
$this->assertTrue($data['is_index'] ?? false);
$this->assertCount(5, $data['rooms'] ?? []);
return $this->response;
});
$controller->index();
}
/**
* @covers \Engelsystem\Controllers\Admin\RoomsController::edit
* @covers \Engelsystem\Controllers\Admin\RoomsController::showEdit
*/
public function testEdit(): void
{
/** @var RoomsController $controller */
$controller = $this->app->make(RoomsController::class);
/** @var Room $room */
$room = Room::factory()->create();
$angelTypes = AngelType::factory(3)->create();
(new NeededAngelType(['room_id' => $room->id, 'angel_type_id' => $angelTypes[0]->id, 'count' => 3]))->save();
$this->response->expects($this->once())
->method('withView')
->willReturnCallback(function (string $view, array $data) use ($room) {
$this->assertEquals('admin/rooms/edit', $view);
$this->assertEquals($room->id, $data['room']?->id);
$this->assertCount(3, $data['angel_types'] ?? []);
$this->assertCount(1, $data['needed_angel_types'] ?? []);
return $this->response;
});
$this->request = $this->request->withAttribute('room_id', 1);
$controller->edit($this->request);
}
/**
* @covers \Engelsystem\Controllers\Admin\RoomsController::edit
* @covers \Engelsystem\Controllers\Admin\RoomsController::showEdit
*/
public function testEditNew(): void
{
/** @var RoomsController $controller */
$controller = $this->app->make(RoomsController::class);
AngelType::factory(3)->create();
$this->response->expects($this->once())
->method('withView')
->willReturnCallback(function (string $view, array $data) {
$this->assertEquals('admin/rooms/edit', $view);
$this->assertEmpty($data['room'] ?? []);
$this->assertCount(3, $data['angel_types'] ?? []);
$this->assertEmpty($data['needed_angel_types'] ?? []);
return $this->response;
});
$controller->edit($this->request);
}
/**
* @covers \Engelsystem\Controllers\Admin\RoomsController::save
*/
public function testSave(): void
{
/** @var RoomsController $controller */
$controller = $this->app->make(RoomsController::class);
$controller->setValidator(new Validator());
AngelType::factory(3)->create();
$this->setExpects($this->redirect, 'to', ['/admin/rooms']);
$this->request = $this->request->withParsedBody([
'name' => 'Testroom',
'description' => 'Something',
'dect' => 'DECTNR',
'map_url' => 'https://osm.url/#map=h/x/y',
'angel_type_1' => '0',
'angel_type_2' => '3',
]);
$controller->save($this->request);
$this->assertTrue($this->log->hasInfoThatContains('Updated room'));
$this->assertHasNotification('room.edit.success');
$this->assertCount(1, Room::whereName('Testroom')->get());
$neededAngelType = NeededAngelType::whereRoomId(1)
->where('angel_type_id', 2)
->where('count', 3)
->get();
$this->assertCount(1, $neededAngelType);
}
/**
* @covers \Engelsystem\Controllers\Admin\RoomsController::save
*/
public function testSaveUniqueName(): void
{
/** @var RoomsController $controller */
$controller = $this->app->make(RoomsController::class);
$controller->setValidator(new Validator());
Room::factory()->create(['name' => 'Testroom']);
$this->request = $this->request->withParsedBody([
'name' => 'Testroom',
]);
$this->expectException(ValidationException::class);
$controller->save($this->request);
}
/**
* @covers \Engelsystem\Controllers\Admin\RoomsController::save
* @covers \Engelsystem\Controllers\Admin\RoomsController::delete
*/
public function testSaveDelete(): void
{
/** @var RoomsController $controller */
$controller = $this->app->make(RoomsController::class);
$controller->setValidator(new Validator());
/** @var Room $room */
$room = Room::factory()->create();
$this->request = $this->request->withParsedBody([
'id' => '1',
'delete' => '1',
]);
$controller->save($this->request);
$this->assertEmpty(Room::find($room->id));
}
/**
* @covers \Engelsystem\Controllers\Admin\RoomsController::delete
*/
public function testDelete(): void
{
/** @var EventDispatcher|MockObject $dispatcher */
$dispatcher = $this->createMock(EventDispatcher::class);
$this->app->instance('events.dispatcher', $dispatcher);
/** @var RoomsController $controller */
$controller = $this->app->make(RoomsController::class);
$controller->setValidator(new Validator());
/** @var Room $room */
$room = Room::factory()->create();
/** @var Shift $shift */
$shift = Shift::factory()->create(['room_id' => $room->id, 'start' => Carbon::create()->subHour()]);
/** @var User $user */
$user = User::factory()->create(['name' => 'foo', 'email' => 'lorem@ipsum']);
/** @var ShiftEntry $shiftEntry */
ShiftEntry::factory()->create(['shift_id' => $shift->id, 'user_id' => $user->id]);
$this->setExpects($this->redirect, 'to', ['/admin/rooms'], $this->response);
$dispatcher->expects($this->once())
->method('dispatch')
->willReturnCallback(function (string $event, array $data) use ($room, $user) {
$this->assertEquals('shift.entry.deleting', $event);
$this->assertEquals($room->id, $data['room']->id);
$this->assertEquals($user->id, $data['user']->id);
return [];
});
$this->request = $this->request->withParsedBody(['id' => 1, 'delete' => '1']);
$controller->delete($this->request);
$this->assertNull(Room::find($room->id));
$this->assertTrue($this->log->hasInfoThatContains('Deleted room'));
$this->assertHasNotification('room.delete.success');
}
public function setUp(): void
{
parent::setUp();
$this->redirect = $this->createMock(Redirector::class);
$this->app->instance(Redirector::class, $this->redirect);
}
}

View File

@ -159,10 +159,10 @@ class ResponseTest extends TestCase
$response = new Response('', 200, [], null, $session);
$response->withInput(['some' => 'value']);
$this->assertEquals(['some' => 'value'], $session->get('form-data'));
$this->assertEquals('value', $session->get('form-data-some'));
$response->withInput(['lorem' => 'ipsum']);
$this->assertEquals(['lorem' => 'ipsum'], $session->get('form-data'));
$this->assertEquals('ipsum', $session->get('form-data-lorem'));
}
/**

View File

@ -171,4 +171,19 @@ class ValidatorTest extends TestCase
['foo' => 'optional|int']
));
}
/**
* @covers \Engelsystem\Http\Validation\Validator::addErrors
*/
public function testAddErrors(): void
{
$val = new Validator();
$val->addErrors(['bar' => ['Lorem']]);
$val->addErrors(['foo' => ['Foo value is definitely wrong!']]);
$this->assertEquals([
'bar' => ['Lorem'],
'foo' => ['Foo value is definitely wrong!'],
], $val->getErrors());
}
}

View File

@ -220,9 +220,7 @@ class ErrorHandlerTest extends TestCase
],
],
],
'form-data' => [
'foo' => 'bar',
],
'form-data-foo' => 'bar',
], $session->all());
$request = $request->withAddedHeader('referer', '/foo/batz');

View File

@ -5,8 +5,8 @@ declare(strict_types=1);
namespace Engelsystem\Test\Unit\Renderer\Twig\Extensions;
use Engelsystem\Renderer\Twig\Extensions\Session;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\HttpFoundation\Session\Session as SymfonySession;
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
class SessionTest extends ExtensionTest
{
@ -16,13 +16,31 @@ class SessionTest extends ExtensionTest
*/
public function testGetGlobals(): void
{
/** @var SymfonySession|MockObject $session */
$session = $this->createMock(SymfonySession::class);
$session = new SymfonySession(new MockArraySessionStorage());
$extension = new Session($session);
$functions = $extension->getFunctions();
$this->assertExtensionExists('session_get', [$session, 'get'], $functions);
$this->assertExtensionExists('session_set', [$session, 'set'], $functions);
$this->assertExtensionExists('session_pop', [$extension, 'sessionPop'], $functions);
}
/**
* @covers \Engelsystem\Renderer\Twig\Extensions\Session::sessionPop
*/
public function testSessionPop(): void
{
$session = new SymfonySession(new MockArraySessionStorage());
$session->set('test', 'value');
$extension = new Session($session);
$result = $extension->sessionPop('test');
$this->assertEquals('value', $result);
$this->assertFalse($session->has('test'));
$result = $extension->sessionPop('foo', 'default value');
$this->assertEquals('default value', $result);
}
}