diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php index d81bbbf100b23db2735d4810e92ad3495e70a00c..149f681dcc7e6c4eeea3828d340ec152ff272021 100644 --- a/lib/classes/JsonApi/RouteMap.php +++ b/lib/classes/JsonApi/RouteMap.php @@ -5,8 +5,6 @@ namespace JsonApi; use JsonApi\Contracts\JsonApiPlugin; use JsonApi\Middlewares\Authentication; use JsonApi\Middlewares\DangerousRouteHandler; -use JsonApi\Middlewares\JsonApi as JsonApiMiddleware; -use JsonApi\Middlewares\StudipMockNavigation; use JsonApi\Routes\Holidays\HolidaysShow; use Slim\Routing\RouteCollectorProxy; @@ -49,7 +47,6 @@ use Slim\Routing\RouteCollectorProxy; * * $this->app->post('/article/{id}/comments', MeineRoute::class); * - * @see \JsonApi\Middlewares\JsonApi * @see \JsonApi\Middlewares\Authentication * @see \JsonApi\Contracts\JsonApiPlugin * @see http://www.slimframework.com/docs/objects/router.html#how-to-create-routes @@ -118,6 +115,7 @@ class RouteMap $group->get('/status-groups/{id}', Routes\StatusgroupShow::class); $this->addAuthenticatedBlubberRoutes($group); + $this->addAuthenticatedClipboardRoutes($group); $this->addAuthenticatedConsultationRoutes($group); $this->addAuthenticatedContactsRoutes($group); $this->addAuthenticatedCoursesRoutes($group); @@ -205,6 +203,21 @@ class RouteMap ); } + private function addAuthenticatedClipboardRoutes(RouteCollectorProxy $group): void + { + $group->post('/clipboards', Routes\Clipboards\ClipboardsCreate::class); + $group->patch('/clipboards/{id}', Routes\Clipboards\ClipboardsUpdate::class); + $group->delete('/clipboards/{id}', Routes\Clipboards\ClipboardsDelete::class); + + $group->get('/clipboard-items/{id}', Routes\Clipboards\ClipboardItemsShow::class); + $group->post('/clipboards/{id}/items', Routes\Clipboards\ClipboardItemsCreate::class); + $group->delete('/clipboards/{id}/items', Routes\Clipboards\ClipboardItemsDelete::class); + $group->delete('/clipboards/{id}/items/{itemId}', Routes\Clipboards\ClipboardItemsDelete::class); + + $group->post('/clipboard-items', Routes\Clipboards\ClipboardItemsCreate::class); + $group->delete('/clipboard-items/{id}', Routes\Clipboards\ClipboardItemsDelete::class); + } + private function addAuthenticatedConsultationRoutes(RouteCollectorProxy $group): void { $group->get('/{type:courses|institutes|users}/{id}/consultations', Routes\Consultations\BlocksByRangeIndex::class); diff --git a/lib/classes/JsonApi/Routes/Clipboards/Authority.php b/lib/classes/JsonApi/Routes/Clipboards/Authority.php new file mode 100644 index 0000000000000000000000000000000000000000..5cc053a4563d0a2c1e37320cb50d8dc1b5bc5acc --- /dev/null +++ b/lib/classes/JsonApi/Routes/Clipboards/Authority.php @@ -0,0 +1,28 @@ +<?php +namespace JsonApi\Routes\Clipboards; + +use User; + +final class Authority +{ + public static function canCreateClipboard(User $user): bool + { + return true; + } + + public static function canAccessClipboard(User $user, \Clipboard $clipboard): bool + { + return $user->id === $clipboard->user_id + || $user->perms === 'root'; + } + + public static function canUpdateClipboard(User $user, \Clipboard $clipboard): bool + { + return self::canAccessClipboard($user, $clipboard); + } + + public static function canDeleteClipboard(User $user, \Clipboard $clipboard): bool + { + return self::canUpdateClipboard($user, $clipboard); + } +} diff --git a/lib/classes/JsonApi/Routes/Clipboards/ClipboardItemsCreate.php b/lib/classes/JsonApi/Routes/Clipboards/ClipboardItemsCreate.php new file mode 100644 index 0000000000000000000000000000000000000000..d57d0c5f28c2d291c651fb97dc7755f91d65a33d --- /dev/null +++ b/lib/classes/JsonApi/Routes/Clipboards/ClipboardItemsCreate.php @@ -0,0 +1,106 @@ +<?php +namespace JsonApi\Routes\Clipboards; + +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\JsonApiController; +use JsonApi\Routes\ValidationTrait; +use JsonApi\Schemas\Clipboard; +use Psr\Http\Message\{ + ResponseInterface as Response, + ServerRequestInterface as Request +}; + +final class ClipboardItemsCreate extends JsonApiController +{ + use ValidationTrait; + + public function __invoke(Request $request, Response $response, $args): Response + { + $json = $this->validate($request, $args); + + $clipboard_id = $args['id'] ?? $json['data']['relationships']['clipboard']['data']['id']; + $clipboard = \Clipboard::find($clipboard_id); + + $user = $this->getUser($request); + if (!Authority::canUpdateClipboard($user, $clipboard)) { + throw new AuthorizationFailedException(); + } + + $range_id = $json['data']['attributes']['range_id']; + $range_type = $json['data']['attributes']['range_type']; + + $item = \ClipboardItem::findOneBySql( + 'clipboard_id = ? AND range_id = ? AND range_type = ?', + [$clipboard_id, $range_id, $range_type] + ); + + if ($item) { + return $this->getCodeResponse(302, [ + 'Location' => $this->getLinkToItem($item), + ]); + } + + $item = \ClipboardItem::create([ + 'clipboard_id' => $clipboard_id, + 'range_id' => $range_id, + 'range_type' => $range_type, + ]); + + return $this->getContentResponse($item); + } + + protected function validateResourceDocument($json, $data) + { + $clipboardValidationError = $this->validateRequestContainsValidClipboard($json, $data); + if ($clipboardValidationError !== null) { + return $clipboardValidationError; + } + + if (!self::arrayHas($json, 'data.attributes.range_id')) { + return 'No range_id defined'; + } + + if (!self::arrayHas($json, 'data.attributes.range_type')) { + return 'No range_type defined'; + } + + $range_type = self::arrayGet($json, 'data.attributes.range_type'); + if (!is_a($range_type, \StudipItem::class, true)) { + return 'Range type must implement interface StudipItem'; + } + + return null; + } + + private function validateRequestContainsValidClipboard($json, $data): ?string + { + if (isset($data['id'])) { + if (!\Clipboard::exists($data['id'])) { + return 'Provided clipboard id is invalid'; + } + } else { + if (!self::arrayHas($json, 'data.relationships.clipboard')) { + return 'No clipboard relationship defined'; + } + + $clipboard = self::arrayGet($json, 'data.relationships.clipboard'); + if ( + !isset($clipboard['data']['type'], $clipboard['data']['id']) + || $clipboard['data']['type'] !== Clipboard::TYPE + ) { + return 'Defined clipboard relationship has invalid format.'; + } + if (!\Clipboard::exists($clipboard['data']['id'])) { + return 'Related clipboard does not exist.'; + } + } + + return null; + } + + private function getLinkToItem(\ClipboardItem $item): string + { + $json = $this->encoder->encodeData($item); + return json_decode($json, true)['data']['links']['self']; + } +} diff --git a/lib/classes/JsonApi/Routes/Clipboards/ClipboardItemsDelete.php b/lib/classes/JsonApi/Routes/Clipboards/ClipboardItemsDelete.php new file mode 100644 index 0000000000000000000000000000000000000000..a9c7cd428ef49ac542951a421acab77e6e126359 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Clipboards/ClipboardItemsDelete.php @@ -0,0 +1,54 @@ +<?php +namespace JsonApi\Routes\Clipboards; + +use JsonApi\Errors\BadRequestException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use Psr\Http\Message\{ + ResponseInterface as Response, + ServerRequestInterface as Request +}; + +final class ClipboardItemsDelete extends JsonApiController +{ + protected $allowedFilteringParameters = ['range_id']; + + public function __invoke(Request $request, Response $response, $args): Response + { + $clipboard = \Clipboard::find($args['id']); + if (!$clipboard) { + throw new RecordNotFoundException('Clipboard not found'); + } + + $user = $this->getUser($request); + if (!Authority::canUpdateClipboard($user, $clipboard)) { + throw new \AccessDeniedException(); + } + + $item = null; + if (isset($args['itemId'])) { + $item = \ClipboardItem::find($args['itemId']); + } else { + $filtering = iterator_to_array($this->getQueryParameters()->getFilters()); + if (!isset($filtering['range_id'])) { + throw new BadRequestException('No range_id filter given'); + } + $item = \ClipboardItem::findOneBySQL( + 'clipboard_id = ? AND range_id = ?', + [$clipboard->id, $filtering['range_id']] + ); + } + + if (!$item) { + throw new RecordNotFoundException('Item not found'); + } + + if ($item->clipboard_id !== $clipboard->id) { + throw new BadRequestException('Item does not belong to clipboard'); + } + + $item->delete(); + + return $this->getCodeResponse(204); + } +} diff --git a/lib/classes/JsonApi/Routes/Clipboards/ClipboardItemsShow.php b/lib/classes/JsonApi/Routes/Clipboards/ClipboardItemsShow.php new file mode 100644 index 0000000000000000000000000000000000000000..3c91708a46f2b1663d4dab43be2ad9d1f6e1d63e --- /dev/null +++ b/lib/classes/JsonApi/Routes/Clipboards/ClipboardItemsShow.php @@ -0,0 +1,28 @@ +<?php +namespace JsonApi\Routes\Clipboards; + +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use Psr\Http\Message\{ + ResponseInterface as Response, + ServerRequestInterface as Request +}; + +final class ClipboardItemsShow extends JsonApiController +{ + public function __invoke(Request $request, Response $response, $args): Response + { + $item = \ClipboardItem::find($args['id']); + if (!$item) { + throw new RecordNotFoundException(); + } + + $user = $this->getUser($request); + if (!Authority::canAccessClipboard($user, $item->clipboard)) { + throw new AuthorizationFailedException(); + } + + return $this->getContentResponse($item); + } +} diff --git a/lib/classes/JsonApi/Routes/Clipboards/ClipboardsCreate.php b/lib/classes/JsonApi/Routes/Clipboards/ClipboardsCreate.php new file mode 100644 index 0000000000000000000000000000000000000000..57fd9b90b259664be79b5846e3c6fe82d094fe34 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Clipboards/ClipboardsCreate.php @@ -0,0 +1,46 @@ +<?php +namespace JsonApi\Routes\Clipboards; + +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\JsonApiController; +use JsonApi\Routes\ValidationTrait; +use Psr\Http\Message\{ + ResponseInterface as Response, + ServerRequestInterface as Request +}; + +final class ClipboardsCreate extends JsonApiController +{ + use ValidationTrait; + + public function __invoke(Request $request, Response $response, $args): Response + { + $user = $this->getUser($request); + + if (!Authority::canCreateClipboard($user)) { + throw new AuthorizationFailedException(); + } + + $json = $this->validate($request, $args); + + $clipboard = \Clipboard::create([ + 'name' => $json['data']['attributes']['name'], + 'user_id' => $user->id, + ]); + + return $this->getContentResponse($clipboard); + } + + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data.attributes.name')) { + return 'No name for the clipboard defined'; + } + + if (!trim(self::arrayGet($json, 'data.attributes.name'))) { + return 'Name of the clipboard may not be empty'; + } + + return null; + } +} diff --git a/lib/classes/JsonApi/Routes/Clipboards/ClipboardsDelete.php b/lib/classes/JsonApi/Routes/Clipboards/ClipboardsDelete.php new file mode 100644 index 0000000000000000000000000000000000000000..0897843bc9bf40d5707fc7c13691c519693f703c --- /dev/null +++ b/lib/classes/JsonApi/Routes/Clipboards/ClipboardsDelete.php @@ -0,0 +1,31 @@ +<?php +namespace JsonApi\Routes\Clipboards; + +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use Psr\Http\Message\{ + ResponseInterface as Response, + ServerRequestInterface as Request +}; + +final class ClipboardsDelete extends JsonApiController +{ + public function __invoke(Request $request, Response $response, $args): Response + { + $clipboard = \Clipboard::find($args['id']); + if (!$clipboard) { + throw new RecordNotFoundException(); + } + + $user = $this->getUser($request); + + if (!Authority::canDeleteClipboard($user, $clipboard)) { + throw new AuthorizationFailedException(); + } + + $clipboard->delete(); + + return $this->getCodeResponse(204); + } +} diff --git a/lib/classes/JsonApi/Routes/Clipboards/ClipboardsUpdate.php b/lib/classes/JsonApi/Routes/Clipboards/ClipboardsUpdate.php new file mode 100644 index 0000000000000000000000000000000000000000..83d9539dbd3d8f26f6205f1d9a946726f2c8a318 --- /dev/null +++ b/lib/classes/JsonApi/Routes/Clipboards/ClipboardsUpdate.php @@ -0,0 +1,50 @@ +<?php +namespace JsonApi\Routes\Clipboards; + +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; +use JsonApi\Routes\ValidationTrait; +use Psr\Http\Message\{ + ResponseInterface as Response, + ServerRequestInterface as Request +}; + +final class ClipboardsUpdate extends JsonApiController +{ + use ValidationTrait; + + public function __invoke(Request $request, Response $response, $args): Response + { + $clipboard = \Clipboard::find($args['id']); + if (!$clipboard) { + throw new RecordNotFoundException(); + } + + $user = $this->getUser($request); + + if (!Authority::canUpdateClipboard($user, $clipboard)) { + throw new AuthorizationFailedException(); + } + + $json = $this->validate($request, $args); + + $clipboard->name = $json['data']['attributes']['name']; + $clipboard->store(); + + return $this->getContentResponse($clipboard); + } + + protected function validateResourceDocument($json, $data) + { + if (!self::arrayHas($json, 'data.attributes.name')) { + return 'No name for the clipboard defined'; + } + + if (!trim(self::arrayGet($json, 'data.attributes.name'))) { + return 'Name of the clipboard may not be empty'; + } + + return null; + } +} diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php index 97212bc65524bc7603199880c399811d81519b55..1498daf692583de42f5132670548f47c616d4915 100644 --- a/lib/classes/JsonApi/SchemaMap.php +++ b/lib/classes/JsonApi/SchemaMap.php @@ -2,6 +2,8 @@ namespace JsonApi; +use JsonApi\Schemas\Clipboard; + /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ @@ -19,6 +21,8 @@ class SchemaMap \BlubberThread::class => Schemas\BlubberThread::class, \CalendarDateAssignment::class => Schemas\CalendarDateAssignment::class, + \Clipboard::class => Schemas\Clipboard::class, + \ClipboardItem::class => Schemas\ClipboardItem::class, \ConsultationBlock::class => Schemas\ConsultationBlock::class, \ConsultationBooking::class => Schemas\ConsultationBooking::class, \ConsultationSlot::class => Schemas\ConsultationSlot::class, diff --git a/lib/classes/JsonApi/Schemas/Clipboard.php b/lib/classes/JsonApi/Schemas/Clipboard.php new file mode 100644 index 0000000000000000000000000000000000000000..af90d73daf2f2195238359f5530632fbfbe0105f --- /dev/null +++ b/lib/classes/JsonApi/Schemas/Clipboard.php @@ -0,0 +1,81 @@ +<?php +namespace JsonApi\Schemas; + +use Neomerx\JsonApi\Contracts\Schema\ContextInterface; +use Neomerx\JsonApi\Schema\Link; + +final class Clipboard extends SchemaProvider +{ + public const TYPE = 'clipboards'; + public const REL_USER = 'user'; + public const REL_ITEMS = 'clipboard-items'; + + /** + * @param \Clipboard $resource + */ + public function getId($resource): ?string + { + return (string) $resource->id; + } + + /** + * @param \Clipboard $resource + */ + public function getAttributes($resource, ContextInterface $context): iterable + { + return [ + 'name' => $resource->name, + 'handler' => $resource->handler, + 'allows_item_class' => $resource->allowed_item_class, + 'mkdate' => date('c', $resource->mkdate), + 'chdate' => date('c', $resource->chdate), + ]; + } + + /** + * @param \Clipboard $resource + */ + public function getRelationships($resource, ContextInterface $context): iterable + { + $relationships = []; + + $isPrimary = $context->getPosition()->getLevel() === 0; + if ($isPrimary) { + $relationships = $this->getUserRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_USER)); + $relationships = $this->getItemsRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_ITEMS)); + } + + + return $relationships; + } + + private function getUserRelationship(array $relationships, \Clipboard $clipboard, bool $includeData): array + { + $relationships[self::REL_USER] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($clipboard->user), + ], + self::RELATIONSHIP_DATA => $includeData ? $clipboard->user : \User::build(['id' => $clipboard->user_id], false), + ]; + + return $relationships; + } + + private function getItemsRelationship(array $relationships, \Clipboard $clipboard, bool $includeData): array + { + if ($includeData) { + $relatedItems = $clipboard->items; + } else { + $relatedItems = $clipboard->items->map(fn($item) => \ClipboardItem::build(['id' => $item->id], false)); + } + + $relationships[self::REL_ITEMS] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->getRelationshipRelatedLink($clipboard, self::REL_ITEMS), + ], + self::RELATIONSHIP_DATA => $relatedItems, + ]; + + return $relationships; + } +} diff --git a/lib/classes/JsonApi/Schemas/ClipboardItem.php b/lib/classes/JsonApi/Schemas/ClipboardItem.php new file mode 100644 index 0000000000000000000000000000000000000000..9c848235eea99d9c4e39db5c10f52034ebc12cf7 --- /dev/null +++ b/lib/classes/JsonApi/Schemas/ClipboardItem.php @@ -0,0 +1,61 @@ +<?php +namespace JsonApi\Schemas; + +use Neomerx\JsonApi\Contracts\Schema\ContextInterface; +use Neomerx\JsonApi\Schema\Link; + +final class ClipboardItem extends SchemaProvider +{ + public const TYPE = 'clipboard-items'; + public const REL_CLIPBOARD = 'clipboard'; + + /** + * @param \ClipboardItem $resource + */ + public function getId($resource): ?string + { + return (string) $resource->id; + } + + /** + * @param \ClipboardItem $resource + */ + public function getAttributes($resource, ContextInterface $context): iterable + { + return [ + 'range_id' => $resource->range_id, + 'range_type' => $resource->range_type, + 'name' => $resource->name, + 'mkdate' => date('c', $resource->mkdate), + 'chdate' => date('c', $resource->chdate), + ]; + } + + /** + * @param \ClipboardItem $resource + */ + public function getRelationships($resource, ContextInterface $context): iterable + { + $relationships = []; + + $isPrimary = $context->getPosition()->getLevel() === 0; + if ($isPrimary) { + $relationships = $this->getClipboardRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_CLIPBOARD)); + } + + + return $relationships; + } + + private function getClipboardRelationship(array $relationships, \ClipboardItem $clipboardItem, bool $includeData): array + { + $relationships[self::REL_CLIPBOARD] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($clipboardItem->clipboard), + ], + self::RELATIONSHIP_DATA => $includeData ? $clipboardItem->clipboard : \User::build(['id' => $clipboardItem->clipboard_id], false), + ]; + + return $relationships; + } +} diff --git a/lib/models/ClipboardItem.class.php b/lib/models/ClipboardItem.class.php index 030388a8e601c2892b42d8c5746991899ef5dc96..888250f16e3c8e85490b73dbcbea503e370764ec 100644 --- a/lib/models/ClipboardItem.class.php +++ b/lib/models/ClipboardItem.class.php @@ -23,6 +23,8 @@ * @property int $mkdate database column * @property int $chdate database column * @property Clipboard $clipboard belongs_to Clipboard + * + * @property-read string $name */ class ClipboardItem extends SimpleORMap { @@ -36,36 +38,32 @@ class ClipboardItem extends SimpleORMap 'assoc_func' => 'find' ]; + $config['additional_fields']['name'] = [ + 'get' => fn(ClipboardItem $item) => $item->__toString(), + ]; + parent::configure($config); } - /** * @returns string representation of this clipboard item. */ public function __toString() { - //Get the class $range_type and the object with ID $range_id, - //if $range_type is a StudipItem: - - $use_generic_name = true; - $object = null; - if (is_subclass_of($this->range_type, 'StudipItem', true)) { + // Get the class $range_type and the object with ID $range_id, + // if $range_type is a StudipItem: + if (is_subclass_of($this->range_type, StudipItem::class)) { $range_class_name = $this->range_type; $object = $range_class_name::find($this->range_id); if ($object) { - $use_generic_name = false; + return $object->getItemName(false); } } - if ($use_generic_name) { - //$range_type is not a class name of a StudipItem class - //or no object of a StudipItem class could be found: - //We cannot determine the name and must therefore use - //a generic name: - return $this->range_type . '_' . $this->range_id; - } else { - return $object->getItemName(false); - } + // $range_type is not a class name of a StudipItem class + // or no object of a StudipItem class could be found: + // We cannot determine the name and must therefore use + // a generic name: + return $this->range_type . '_' . $this->range_id; } } diff --git a/resources/assets/javascripts/bootstrap/clipboard.js b/resources/assets/javascripts/bootstrap/clipboard.js index e525b35674483a4016a81f67485d9b6b9aef5d49..a64a605dfc7ad620ceec3519eabf23753d29e5ae 100644 --- a/resources/assets/javascripts/bootstrap/clipboard.js +++ b/resources/assets/javascripts/bootstrap/clipboard.js @@ -25,7 +25,9 @@ STUDIP.domReady(function () { jQuery(document).on('click', '.clipboard-remove-button', function (event) { event.preventDefault(); - STUDIP.Dialog.confirm($(this).data('confirm-message'), function() { + + const message = $(this).data('confirm-message'); + STUDIP.Dialog.confirm(message).done(() => { STUDIP.Clipboard.handleRemoveClick(event.target); }); }); @@ -62,10 +64,11 @@ STUDIP.domReady(function () { }); }); - jQuery(document).on('submit', '.clipboard-widget .new-clipboard-form', function (event) { - event.preventDefault(); - STUDIP.Clipboard.handleAddForm(event); - }); + jQuery(document).on( + 'submit', + '.clipboard-widget .new-clipboard-form', + STUDIP.Clipboard.handleAddForm + ); jQuery(document).on('click', '.clipboard-add-item-button', function (event) { event.preventDefault(); diff --git a/resources/assets/javascripts/lib/abstract-api.js b/resources/assets/javascripts/lib/abstract-api.js index eafca85f1252783eaf55ada6340168116d958b83..cf9aed15a09ebd84018e5f58fc4b6739947b9684 100644 --- a/resources/assets/javascripts/lib/abstract-api.js +++ b/resources/assets/javascripts/lib/abstract-api.js @@ -52,6 +52,8 @@ class AbstractAPI var deferred; + const request = this.#createRequest(url, options); + if (options.async && this.request_count > 0) { // Request should be sent asynchronous after every other request // is finished. The configuration for this particular request is @@ -73,10 +75,10 @@ class AbstractAPI this.total_requests += 1; // Actual request - deferred = $.ajax(STUDIP.URLHelper.getURL(`${this.base_url}/${url}`, {}, true), { + deferred = $.ajax(request.url, { contentType: options.contentType || 'application/x-www-form-urlencoded; charset=UTF-8', method: options.method.toUpperCase(), - data: this.encodeData(options.data, options.method.toUpperCase()), + data: this.encodeData(request.data, options.method.toUpperCase()), headers: options.headers }).always(() => { // Decrease request counter, remove overlay if neccessary @@ -93,6 +95,27 @@ class AbstractAPI } }).promise(); } + + #createRequest(url, options) { + const hasBody = ['post', 'put', 'patch'].includes(options.method.toLowerCase()); + const query = hasBody ? '' : `?${this.convertDataToRequestParameters(options.data)}`; + + return { + url: STUDIP.URLHelper.getURL(`${this.base_url}/${url}${query}`, {}, true), + data: hasBody ? options.data : {}, + }; + } + + convertDataToRequestParameters(data, prefix = '') { + return Object.entries(data).map(([key, value]) => { + const name = prefix ? `${prefix}[${key}]` : `${key}`; + if (value.constructor.name === 'Object') { + return this.convertDataToRequestParameters(value, name); + } else { + return `${name}=${value}`; + } + }).join('&'); + } } // Create shortcut methods for easier access by method diff --git a/resources/assets/javascripts/lib/clipboard.js b/resources/assets/javascripts/lib/clipboard.js index e5890abff4312f5711cdf26542b937209534ff95..8af31116ee0777d02e87bab3a9d6b7ef4ddb6a24 100644 --- a/resources/assets/javascripts/lib/clipboard.js +++ b/resources/assets/javascripts/lib/clipboard.js @@ -1,4 +1,14 @@ -import {$gettext} from './gettext'; +function extractAttribute(node, attribute) { + return node.querySelector(`input[name="${attribute}"]`)?.value.trim(); +} + +function extractAttributes(node, attributes) { + const result = {}; + for (const key of attributes) { + result[key] = extractAttribute(node, key); + } + return result; +} const Clipboard = { switchClipboard: function(event) { @@ -32,32 +42,30 @@ const Clipboard = { } }, - handleAddForm: function(event) { - if (!event) { - return false; - } - + handleAddForm(event) { + event.preventDefault(); + const attributes = extractAttributes(event.target, ['name', 'allowed_item_class']); //Check if a name is entered in the form: - let name_input = jQuery(event.target).find('input[type="text"][name="name"]'); + const name_input = event.target.querySelector('input[name="name"]'); if (!name_input) { //Something is wrong with the HTML: return false; } - let name = jQuery(name_input).val().trim(); - if (!name) { + if (!attributes.name) { //The name field is empty. Why send an empty field? return false; } - //Submit the form via AJAX: - STUDIP.api.POST( - 'clipboard/add', - { - data: jQuery(event.target).serialize() - } - ).done(STUDIP.Clipboard.add); + // Submit the form via AJAX: + STUDIP.jsonapi.POST('clipboards', {data: {data: {attributes}}}).done(({data}) => { + STUDIP.Clipboard.add({ + id: data.id, + name: data.attributes.name, + widget_id: extractAttribute(event.target, 'widget_id') + }); + }); }, add: function(data) { @@ -134,11 +142,9 @@ const Clipboard = { jQuery(widget_node).find('#clipboard-group-container').removeClass('invisible'); //Call the droppable jQuery method on the new clipboard area: - jQuery(clipboard_node).droppable( - { - drop: STUDIP.Clipboard.handleItemDrop - } - ); + jQuery(clipboard_node).droppable({ + drop: STUDIP.Clipboard.handleItemDrop + }); //Clear the text input in the "add clipboard" form: jQuery(widget_node).find( @@ -238,17 +244,19 @@ const Clipboard = { } //Add the item to the clipboard via AJAX: - STUDIP.api.POST( - 'clipboard/' + clipboard_id + '/item', - { + STUDIP.jsonapi.POST(`clipboards/${clipboard_id}/items`, { + data: { data: { - 'range_id': range_id, - 'range_type': range_type, - 'widget_id': widget_id + attributes: { range_id, range_type } } } - ).done(function(data) { - STUDIP.Clipboard.addDroppedItem(data); + }).done(({data}) => { + STUDIP.Clipboard.addDroppedItem({ + id: data.id, + name: data.attributes.name, + range_id: data.attributes.range_id, + widget_id + }); }); }, @@ -263,6 +271,7 @@ const Clipboard = { let widget = jQuery('#ClipboardWidget_' + response_data['widget_id']); let clipboard_id = jQuery(widget).find(".clipboard-selector").val(); + if (!widget) { //The widget with the speicified widget-ID //is not present on the current page. @@ -325,25 +334,16 @@ const Clipboard = { ); }, - rename: function(widget_id) { - if (!widget_id) { - //Required data are missing! - return; - } + rename(widget_id) { + const widget = jQuery('#ClipboardWidget_' + widget_id); + const clipboard_id = widget.find('.clipboard-selector').val(); + const name = widget.find('input.clipboard-name').val(); - let widget = jQuery('#ClipboardWidget_' + widget_id); - let clipboard_id = jQuery(widget).find(".clipboard-selector").val(); - let namer = jQuery(widget).find("input.clipboard-name"); - - STUDIP.api.PUT( - 'clipboard/' + clipboard_id, - { - data: { - name: namer.val() - } - } - ).done(function(data) { - STUDIP.Clipboard.update(data, widget_id) + STUDIP.jsonapi.PATCH(`clipboards/${clipboard_id}`, {data: {data: {attributes: {name}}}}).done(({data}) => { + STUDIP.Clipboard.update({ + id: data.id, + name: data.attributes.name, + }, widget_id) }); }, @@ -358,7 +358,7 @@ const Clipboard = { STUDIP.Clipboard.toggleEditButtons(widget_id); }, - remove: function(clipboard_id, widget_id) { + remove(clipboard_id, widget_id) { if (!clipboard_id || !widget_id) { //Required data are missing! return; @@ -427,10 +427,6 @@ const Clipboard = { }, handleRemoveClick: function(delete_icon) { - if (!delete_icon) { - return; - } - //Get the data of the clipboard: let clipboard_select = jQuery(delete_icon).siblings('.clipboard-selector')[0]; if (!clipboard_select) { @@ -444,52 +440,42 @@ const Clipboard = { //Another case where something is wrong with the HTML. return; } - let widget_id = jQuery(widget).data('widget_id'); - STUDIP.api.DELETE( - 'clipboard/' + clipboard_id, - { - data: { - widget_id: widget_id - } - } - ).done(function() { + const widget_id = jQuery(widget).data('widget_id'); + + STUDIP.jsonapi.DELETE(`clipboards/${clipboard_id}`).done(() => { STUDIP.Clipboard.remove(clipboard_id, widget_id); }); }, - removeItem: function(delete_icon) { - if (!delete_icon) { - return; - } - - //Get the item-ID: - let item_html = jQuery(delete_icon).parents('tr'); - let range_id = jQuery(item_html).data('range_id'); - let clipboard_element = jQuery(item_html).parents('table'); - let clipboard_id = jQuery(clipboard_element).data('id'); + removeItem(delete_icon) { + // Get the item-ID: + const item_element = jQuery(delete_icon).parents('tr'); + const range_id = jQuery(item_element).data('range_id'); + const clipboard_element = jQuery(item_element).parents('table'); + const clipboard_id = jQuery(clipboard_element).data('id'); if (!range_id || !clipboard_id) { //We cannot proceed without the item-ID and the clipboard-ID! return; } - STUDIP.api.DELETE( - 'clipboard/' + clipboard_id + '/item/' + range_id - ).done(function() { + STUDIP.jsonapi.DELETE(`clipboards/${clipboard_id}/items`, { + data: { + filter: { range_id } + } + }).done(() => { //Check if the item has siblings: - let siblings = jQuery(item_html).siblings(); + let siblings = item_element.siblings(); if (siblings.length < 3) { //Only the "no items" element and the template //are siblings of the item. //We must display the "no items" element: - jQuery(item_html).siblings( - '.empty-clipboard-message' - ).removeClass('invisible'); + item_element.siblings('.empty-clipboard-message').removeClass('invisible'); jQuery("#clipboard-group-container").find('.widget-links').addClass('invisible'); } //Finally remove the item: - jQuery(item_html).remove(); + item_element.remove(); }); }, diff --git a/tests/_support/Helper/StudipDb.php b/tests/_support/Helper/StudipDb.php index c80338ced5022448fe1894191f89802132f23455..b95e703c056ad462a1815d136a2c8a48e9c0da65 100644 --- a/tests/_support/Helper/StudipDb.php +++ b/tests/_support/Helper/StudipDb.php @@ -11,19 +11,11 @@ class StudipDb extends \Codeception\Module { /** * @api - * - * @var */ public ?\StudipPdo $dbh; - /** - * @var array - */ protected array $config = []; - /** - * @var array - */ protected array $requiredFields = ['dsn', 'user', 'password']; /** diff --git a/tests/jsonapi/ClipboardRoutesTest.php b/tests/jsonapi/ClipboardRoutesTest.php new file mode 100644 index 0000000000000000000000000000000000000000..71a851a9d9b9ea240a3427fded68f11e9b4844d0 --- /dev/null +++ b/tests/jsonapi/ClipboardRoutesTest.php @@ -0,0 +1,167 @@ +<?php + +use JsonApi\Routes\Clipboards\ClipboardItemsCreate; +use JsonApi\Routes\Clipboards\ClipboardItemsDelete; +use JsonApi\Routes\Clipboards\ClipboardsCreate; +use JsonApi\Routes\Clipboards\ClipboardsDelete; +use JsonApi\Routes\Clipboards\ClipboardsUpdate; +use JsonApi\Schemas\Clipboard as ClipboardSchema; +use JsonApi\Schemas\ClipboardItem as ClipboardItemSchema; +use WoohooLabs\Yang\JsonApi\Response\JsonApiResponse; +use WoohooLabs\Yang\JsonApi\Schema\Resource\ResourceObject; + +require_once __DIR__ . '/JSONAPIHelperTrait.php'; + +class ClipboardRoutesTest extends Codeception\Test\Unit +{ + use JSONAPIHelperTrait; + + public function testCreateClipboard(): void + { + $resource = $this->createClipboard( + $this->tester->getCredentialsForTestDozent() + ); + + $this->assertHasRelations($resource, 'user', 'clipboard-items'); + $this->assertEquals(ClipboardSchema::TYPE, $resource->type()); + $this->assertEquals('Test-Clipboard', $resource->attribute('name')); + } + + public function testUpdateClipboard(): void + { + $credentials = $this->tester->getCredentialsForTestDozent(); + $resource = $this->createClipboard($credentials); + + $response = $this->sendMockRequest( + "/clipboards/{id}", + ClipboardsUpdate::class, + $credentials, + ['id' => $resource->id()], + [ + 'considered_successful' => [200], + 'method' => 'PATCH', + 'json_body' => [ + 'data' => [ + 'attributes' => ['name' => 'Foo Bar'], + ], + ], + ], + ); + + $resource = $this->getResourceFromResponse($response); + + $this->assertEquals('Foo Bar', $resource->attribute('name')); + } + + public function testDeleteClipboard(): void + { + $credentials = $this->tester->getCredentialsForTestDozent(); + + $resource = $this->createClipboard($credentials); + + $this->sendMockRequest( + "/clipboards/{id}", + ClipboardsDelete::class, + $credentials, + ['id' => $resource->id()], + [ + 'considered_successful' => [204], + 'method' => 'DELETE', + ], + ); + } + + public function testAddItemToClipboard(): void + { + $credentials = $this->tester->getCredentialsForTestDozent(); + $resource = $this->createClipboard($credentials); + + $resource = $this->createClipboardItem( + $credentials, + $resource->id(), + 'abcd1234', + 'Room' + ); + + $this->assertHasRelations($resource, 'clipboard'); + $this->assertEquals(ClipboardItemSchema::TYPE, $resource->type()); + $this->assertEquals('abcd1234', $resource->attribute('range_id')); + $this->assertEquals('Room', $resource->attribute('range_type')); + } + + public function testRemoveItemFromClipboard(): void + { + $credentials = $this->tester->getCredentialsForTestDozent(); + $clipboard = $this->createClipboard($credentials); + $item = $this->createClipboardItem( + $credentials, + $clipboard->id(), + 'abcd1234', + 'Room' + ); + + $this->sendMockRequest( + "/clipboards/{id}/items/{itemId}", + ClipboardItemsDelete::class, + $credentials, + [ + 'id' => $clipboard->id(), + 'itemId' => $item->id(), + ], + [ + 'considered_successful' => [204], + 'method' => 'DELETE', + ], + ); + } + + protected function createClipboard(array $credentials, string $name = 'Test-Clipboard'): ResourceObject + { + $response = $this->sendMockRequest( + "/clipboards", + ClipboardsCreate::class, + $credentials, + [], + [ + 'considered_successful' => [200], + 'method' => 'POST', + 'json_body' => [ + 'data' => [ + 'type' => ClipboardSchema::TYPE, + 'attributes' => ['name' => $name], + ], + ], + ], + ); + + return $this->getResourceFromResponse($response); + } + + protected function createClipboardItem( + array $credentials, + string $clipboard_id, + string $range_id, + string $range_type + ): ResourceObject { + $response = $this->sendMockRequest( + "/clipboards/{id}/items", + ClipboardItemsCreate::class, + $credentials, + ['id' => $clipboard_id], + [ + 'considered_successful' => [200], + 'method' => 'POST', + 'json_body' => [ + 'data' => [ + 'attributes' => [ + 'range_id' => $range_id, + 'range_type' => $range_type, + ], + ], + ], + ], + ); + + return $this->getResourceFromResponse($response); + } +} diff --git a/tests/jsonapi/ConsultationHelper.php b/tests/jsonapi/ConsultationHelper.php index 673174e13660e96451c72e3301d54e7fb25ea52f..f84820b3125f4ca26118b08d8a11439e42580f47 100644 --- a/tests/jsonapi/ConsultationHelper.php +++ b/tests/jsonapi/ConsultationHelper.php @@ -1,19 +1,9 @@ <?php -use WoohooLabs\Yang\JsonApi\Response\JsonApiResponse; -use WoohooLabs\Yang\JsonApi\Schema\Document; -use WoohooLabs\Yang\JsonApi\Schema\Resource\ResourceObject; +require_once __DIR__ . '/JSONAPIHelperTrait.php'; trait ConsultationHelper { - /** - * @var \UnitTester - */ - protected $tester; - - protected function _before() - { - \DBManager::getInstance()->setConnection('studip', $this->getModule('\\Helper\\StudipDb')->dbh); - } + use JSONAPIHelperTrait; protected static $BLOCK_DATA = [ 'room' => 'Testraum', @@ -88,23 +78,6 @@ trait ConsultationHelper return $block->slots->first(); } - protected function withStudipEnv(array $credentials, callable $fn) - { - // Create global template factory if neccessary - $has_template_factory = isset($GLOBALS['template_factory']); - if (!$has_template_factory) { - $GLOBALS['template_factory'] = new Flexi\Factory($GLOBALS['STUDIP_BASE_PATH'] . '/templates'); - } - - $result = $this->tester->withPHPLib($credentials, $fn); - - if (!$has_template_factory) { - unset($GLOBALS['template_factory']); - } - - return $result; - } - protected function createBookingForSlot(array $credentials, ConsultationSlot $slot, User $user): ConsultationBooking { return $this->withStudipEnv( @@ -122,81 +95,4 @@ trait ConsultationHelper } ); } - - protected function sendMockRequest(string $route, string $handler, array $credentials, array $variables = [], array $options = []): JsonApiResponse - { - $options = array_merge([ - 'method' => 'GET', - 'considered_successful' => [200], - 'json_body' => null, - ], $options); - - $app = $this->tester->createApp( - $credentials, - strtolower($options['method']), - $route, - $handler - ); - - $evaluated_route = preg_replace_callback( - '/\{(.+?)(:[^}]+)?}/', - function ($match) use ($variables) { - $key = $match[1]; - if (!isset($variables[$key])) { - throw new Exception("No variable '{$key}' defined"); - } - return $variables[$key]; - }, - $route - ); - - $requestBuilder = $this->tester->createRequestBuilder($credentials); - $requestBuilder->setUri($evaluated_route)->setMethod(strtoupper($options['method'])); - - if (isset($options['json_body'])) { - $requestBuilder->setJsonApiBody($options['json_body']); - - } - - /** @var JsonApiResponse $response */ - $response = $this->withStudipEnv($credentials, function () use ($app, $requestBuilder) { - return $this->tester->sendMockRequest($app, $requestBuilder->getRequest()); - }); - - if ($options['considered_successful']) { - $this->assertTrue( - $response->isSuccessful($options['considered_successful']), - 'Actual status code is ' . $response->getStatusCode() - ); - } - - return $response; - } - - protected function getSingleResourceDocument(JsonApiResponse $response): Document - { - $this->assertTrue($response->hasDocument()); - - $document = $response->document(); - $this->assertTrue($document->isSingleResourceDocument()); - - return $document; - } - - protected function getResourceCollectionDocument(JsonApiResponse $response): Document - { - $this->assertTrue($response->hasDocument()); - - $document = $response->document(); - $this->assertTrue($document->isResourceCollectionDocument()); - - return $document; - } - - protected function assertHasRelations(ResourceObject $resource, ...$relations) - { - foreach ($relations as $relation) { - $this->assertTrue($resource->hasRelationship($relation)); - } - } } diff --git a/tests/jsonapi/JSONAPIHelperTrait.php b/tests/jsonapi/JSONAPIHelperTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..666e1981f29dacc83449024b49d6f95c60e3fa8d --- /dev/null +++ b/tests/jsonapi/JSONAPIHelperTrait.php @@ -0,0 +1,117 @@ +<?php + +use WoohooLabs\Yang\JsonApi\Response\JsonApiResponse; +use WoohooLabs\Yang\JsonApi\Schema\Document; +use WoohooLabs\Yang\JsonApi\Schema\Resource\ResourceObject; + +trait JSONAPIHelperTrait +{ + protected JSONAPITester $tester; + + protected function _before() + { + DBManager::getInstance()->setConnection( + 'studip', + $this->getModule('\\Helper\\StudipDb')->dbh + ); + } + + protected function withStudipEnv(array $credentials, callable $fn) + { + // Create global template factory if neccessary + $has_template_factory = isset($GLOBALS['template_factory']); + if (!$has_template_factory) { + $GLOBALS['template_factory'] = new Flexi\Factory($GLOBALS['STUDIP_BASE_PATH'] . '/templates'); + } + + $result = $this->tester->withPHPLib($credentials, $fn); + + if (!$has_template_factory) { + unset($GLOBALS['template_factory']); + } + + return $result; + } + + protected function sendMockRequest(string $route, string $handler, array $credentials, array $variables = [], array $options = []): JsonApiResponse + { + $options = array_merge([ + 'method' => 'GET', + 'considered_successful' => [200], + 'json_body' => null, + ], $options); + + $app = $this->tester->createApp( + $credentials, + strtolower($options['method']), + $route, + $handler + ); + + $evaluated_route = preg_replace_callback( + '/\{(.+?)(:[^}]+)?}/', + function ($match) use ($variables) { + $key = $match[1]; + if (!isset($variables[$key])) { + throw new Exception("No variable '{$key}' defined"); + } + return $variables[$key]; + }, + $route + ); + + $requestBuilder = $this->tester->createRequestBuilder($credentials); + $requestBuilder->setUri($evaluated_route)->setMethod(strtoupper($options['method'])); + + if (isset($options['json_body'])) { + $requestBuilder->setJsonApiBody($options['json_body']); + + } + + /** @var JsonApiResponse $response */ + $response = $this->withStudipEnv($credentials, function () use ($app, $requestBuilder) { + return $this->tester->sendMockRequest($app, $requestBuilder->getRequest()); + }); + + if ($options['considered_successful']) { + $this->assertTrue( + $response->isSuccessful($options['considered_successful']), + 'Actual status code is ' . $response->getStatusCode() + ); + } + + return $response; + } + + protected function getSingleResourceDocument(JsonApiResponse $response): Document + { + $this->assertTrue($response->hasDocument()); + + $document = $response->document(); + $this->assertTrue($document->isSingleResourceDocument()); + + return $document; + } + + protected function getResourceCollectionDocument(JsonApiResponse $response): Document + { + $this->assertTrue($response->hasDocument()); + + $document = $response->document(); + $this->assertTrue($document->isResourceCollectionDocument()); + + return $document; + } + + protected function assertHasRelations(ResourceObject $resource, ...$relations) + { + foreach ($relations as $relation) { + $this->assertTrue($resource->hasRelationship($relation)); + } + } + + protected function getResourceFromResponse(JsonApiResponse $response): ResourceObject + { + return $this->getSingleResourceDocument($response)->primaryResource(); + } +}